Compare commits
141 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a2ed11156 | |||
| e41c1702a3 | |||
| d5351c6836 | |||
| c2bcd5a2f2 | |||
| d8ef20ba8c | |||
| 0e6a14dd10 | |||
| 9b4fbabbc5 | |||
| 879355f792 | |||
| cd0fc4c488 | |||
| eb07cb67a2 | |||
| d5167448df | |||
| 9051093fb3 | |||
| 710ae134b0 | |||
| 5a9e9b52d6 | |||
| b35cda27a6 | |||
| 886bfa4b1e | |||
| b0535af75c | |||
| 8bcc40f217 | |||
| 7cc69b4e65 | |||
| 69cc25b99a | |||
| 944ec3432d | |||
| f1c0921a58 | |||
| c72451e0b7 | |||
| a49aa6717f | |||
| 49c8310089 | |||
| ad2b2050a3 | |||
| 47bd38f832 | |||
| 7ec4d75a8a | |||
| 4b2dd28583 | |||
| 30ebe7c773 | |||
| 906338fbea | |||
| 28a67c8d8c | |||
| b6d2995916 | |||
| a70c1501e9 | |||
| ed6e25c219 | |||
| 85b753b587 | |||
| ca5c966c9c | |||
| a1f06566d4 | |||
| 9f92d0d212 | |||
| a169449390 | |||
| d9afa32828 | |||
|
|
101f078f51 | ||
|
|
6e314f081d | ||
|
|
9ddab71d83 | ||
|
|
ad8c43dddc | ||
|
|
2f5f4ce816 | ||
|
|
5913305be9 | ||
|
|
a9f577fc06 | ||
|
|
8da858d1d8 | ||
|
|
fac7ed1e6b | ||
|
|
e11f0d8b4a | ||
|
|
19fa423210 | ||
|
|
3534363f98 | ||
|
|
6621e87b56 | ||
|
|
f570ac4fce | ||
|
|
b3db1ef9f5 | ||
|
|
db4876e085 | ||
|
|
9546e13001 | ||
|
|
59c91e4413 | ||
| ce1c8fac78 | |||
| 01dd4979d8 | |||
| 8f2e33559e | |||
| fbd225ea6d | |||
| 32769077bc | |||
| 2a3d57159a | |||
| 34e73a47d1 | |||
| 4dad380a50 | |||
| 33443b9da6 | |||
| edf3aada73 | |||
| 33bbf7ff8c | |||
| 8dab5bbd82 | |||
|
|
db3d15b127 | ||
|
|
088ff1e849 | ||
|
|
761e7c0e16 | ||
|
|
8efd0dc61c | ||
|
|
71f6218ea1 | ||
|
|
f892a1ceba | ||
|
|
91a17acf04 | ||
|
|
10558c47ec | ||
|
|
9731e10ee0 | ||
| cacbf24bfe | |||
| 0a14f11eae | |||
| 1c18e91671 | |||
| d8b2f06f86 | |||
| 6b4414c9ce | |||
| ae2347c7ce | |||
| 036e4b9e7a | |||
| c1cc28d6e8 | |||
| 2d780c9d57 | |||
| a1bbb05ee2 | |||
| 69e5583e74 | |||
| ba9e297cb8 | |||
| 3590cb748f | |||
| 98f7501e07 | |||
| c8c579eefe | |||
| b97fe72f19 | |||
| dc6e235f27 | |||
| f2e58f6ba0 | |||
| 00446c5fb8 | |||
| 07198c20b7 | |||
| 0f8d27ac7a | |||
| 5fe70d754b | |||
| 97f69b4cff | |||
| 1e384032be | |||
| d82f704c75 | |||
| 9045b374fa | |||
| c8d5bf2c4b | |||
| 4531b99be4 | |||
| 148ddbe9bc | |||
| 16263d88bc | |||
| c40c3f1f12 | |||
| e61c568f77 | |||
| 432611d4ee | |||
| 1aea27e576 | |||
| 3d52144ad7 | |||
| 7fa2b1819a | |||
| 702ad29822 | |||
| a611f1beec | |||
| a41a6f35fd | |||
| 813ddeb802 | |||
| 53869aa670 | |||
| 22b3c2a3c4 | |||
| f734fba0db | |||
| d794502508 | |||
| 57d7082ec0 | |||
| d155ebfcb0 | |||
| fd9251fc6e | |||
| be8bf3fa5c | |||
| 8d15d05c3d | |||
| 9974a2b6b3 | |||
| 058473260a | |||
| b5fd8b6526 | |||
| fe05572549 | |||
| d116bb4141 | |||
| cc94e85105 | |||
| 6372014c99 | |||
| d41339f196 | |||
| dd24225120 | |||
| 2e13142b38 | |||
| b5cb907283 | |||
| 997b897b28 |
138 changed files with 22258 additions and 54333 deletions
44
.gitignore
vendored
44
.gitignore
vendored
|
|
@ -1,26 +1,42 @@
|
|||
# Fichiers d'environnement (contiennent des tokens/clés sensibles)
|
||||
# --- SÉCURITÉ ---
|
||||
# Identifiants et tokens (LE PLUS IMPORTANT)
|
||||
.env
|
||||
|
||||
# Fichiers de configuration générés
|
||||
# --- DONNÉES LOCALES (Ignorées pour la prod) ---
|
||||
# Fichiers JSON de config et BDD (pour ne pas les écraser lors du git reset)
|
||||
config.json
|
||||
whitelist.json
|
||||
bridges.json
|
||||
malware.json
|
||||
config.db
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Cache Python
|
||||
# Dossiers de stockage
|
||||
data/
|
||||
.data/
|
||||
backups/ # <--- IMPORTANT : pour ne pas versionner tes sauvegardes ZIP
|
||||
|
||||
# --- PYTHON ---
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# Environnements virtuels
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
venv/
|
||||
env/
|
||||
.venv/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
# --- LOGS ---
|
||||
*.log
|
||||
*.Logs
|
||||
logs/
|
||||
|
||||
# --- IDE & SYSTÈME ---
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
*.Logs
|
||||
.data
|
||||
.DS_Store
|
||||
.roo/
|
||||
.continue/mcpServers/new-mcp-server.yaml
|
||||
.continue/mcpServers/new-mcp-server-1.yaml
|
||||
.gitignore
|
||||
|
|
|
|||
BIN
EXEMPLE_ARRIVER.png
Normal file
BIN
EXEMPLE_ARRIVER.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 315 KiB |
319
EXPLICATION_NON_DEV.md
Normal file
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
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*
|
||||
15
TODO.md
Normal file
15
TODO.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# TODO - Désactivation fonctionnalités premium (SKU) & correction accès /ticket
|
||||
|
||||
## Étape 1 — Compréhension
|
||||
- [x] Identifier le check premium bloquant /ticket (utils/premium.py)
|
||||
- [x] Vérifier où le check est appliqué (commandes/ticket/__init__.py)
|
||||
|
||||
## Étape 2 — Mise en conformité
|
||||
- [x] Désactivation temporaire via env var : DISABLE_PREMIUM_SKU_CHECK=true
|
||||
- [ ] Ajouter un switch clair pour ré-activer facilement (déjà géré par la même env var)
|
||||
|
||||
|
||||
## Étape 3 — Validation
|
||||
- [ ] Lancer un lint/tests rapides ou démarrer le bot (si possible)
|
||||
- [ ] Vérifier que /ticket répond sur le serveur bloqué (Omega Kube)
|
||||
|
||||
259
TÂCHE_VICY.py
Executable file
259
TÂCHE_VICY.py
Executable file
|
|
@ -0,0 +1,259 @@
|
|||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands, tasks
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from utils.gestion_taches import (
|
||||
ajouter_tache, lister_taches_utilisateur, taches_proches,
|
||||
update_tache_status, charger_taches, supprimer_tache, sauvegarder_taches
|
||||
)
|
||||
|
||||
PRIORITES = ["Haute", "Moyenne", "Basse", "Critique", "Faible"]
|
||||
|
||||
class RappelTacheView(discord.ui.View):
|
||||
def __init__(self, tid: str):
|
||||
super().__init__(timeout=None)
|
||||
self.tid = tid
|
||||
|
||||
@discord.ui.button(label="✅ Marquer comme faite", style=discord.ButtonStyle.success, custom_id="btn_mark_done")
|
||||
async def mark_done(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
success = await update_tache_status(self.tid, "Terminée")
|
||||
if success:
|
||||
await interaction.response.send_message(
|
||||
f"✅ La tâche #{self.tid} a été marquée comme terminée !", ephemeral=True)
|
||||
else:
|
||||
await interaction.response.send_message(
|
||||
"❌ Impossible de mettre à jour la tâche. Peut-être déjà terminée ?", ephemeral=True)
|
||||
self.disable_all_items()
|
||||
await interaction.message.edit(view=self)
|
||||
|
||||
class MesTachesPaginator(discord.ui.View):
|
||||
def __init__(self, interaction: discord.Interaction, taches_list: list[tuple], per_page=5):
|
||||
super().__init__(timeout=120)
|
||||
self.interaction = interaction
|
||||
self.taches = taches_list
|
||||
self.per_page = per_page
|
||||
self.page = 0
|
||||
self.max_page = (len(taches_list) - 1) // per_page
|
||||
|
||||
def get_embed(self):
|
||||
embed = discord.Embed(title="📝 Vos Tâches Assignées", color=discord.Color.green())
|
||||
start = self.page * self.per_page
|
||||
end = start + self.per_page
|
||||
for tid, t in self.taches[start:end]:
|
||||
embed.add_field(
|
||||
name=f"#{tid} - {t['titre']}",
|
||||
value=f"📅 Échéance : {t['échéance']}\n⚠️ Priorité : {t['priorité']}\n📌 Statut : {t['statut']}",
|
||||
inline=False
|
||||
)
|
||||
embed.set_footer(text=f"Page {self.page + 1} / {self.max_page + 1}")
|
||||
return embed
|
||||
|
||||
@discord.ui.button(label="⬅️", style=discord.ButtonStyle.secondary)
|
||||
async def previous_page(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
if interaction.user.id != self.interaction.user.id:
|
||||
await interaction.response.send_message("❌ Ce n'est pas votre pagination.", ephemeral=True)
|
||||
return
|
||||
if self.page > 0:
|
||||
self.page -= 1
|
||||
await interaction.response.edit_message(embed=self.get_embed(), view=self)
|
||||
else:
|
||||
await interaction.response.defer()
|
||||
|
||||
@discord.ui.button(label="➡️", style=discord.ButtonStyle.secondary)
|
||||
async def next_page(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
if interaction.user.id != self.interaction.user.id:
|
||||
await interaction.response.send_message("❌ Ce n'est pas votre pagination.", ephemeral=True)
|
||||
return
|
||||
if self.page < self.max_page:
|
||||
self.page += 1
|
||||
await interaction.response.edit_message(embed=self.get_embed(), view=self)
|
||||
else:
|
||||
await interaction.response.defer()
|
||||
|
||||
class JiraDiscord(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.rappels.start()
|
||||
|
||||
def cog_unload(self):
|
||||
self.rappels.cancel()
|
||||
|
||||
async def autocomplete_priorite(self, interaction: discord.Interaction, current: str):
|
||||
return [
|
||||
app_commands.Choice(name=prio, value=prio)
|
||||
for prio in PRIORITES if current.lower() in prio.lower()
|
||||
][:5]
|
||||
|
||||
@app_commands.command(name="nouvelle_tache", description="Créer une nouvelle tâche")
|
||||
@app_commands.describe(
|
||||
titre="Titre de la tâche",
|
||||
description="Description de la tâche",
|
||||
assigne="Utilisateur assigné",
|
||||
priorite="Niveau de priorité (ex: Haute, Moyenne, Basse)",
|
||||
echeance="Date d'échéance au format JJ/MM/AAAA"
|
||||
)
|
||||
async def nouvelle_tache(
|
||||
self, interaction: discord.Interaction, titre: str, description: str,
|
||||
assigne: discord.User, priorite: str, echeance: str
|
||||
):
|
||||
data = await charger_taches()
|
||||
for tid, tache in data.get("taches", {}).items():
|
||||
if tache["assigné"] == assigne.id and tache["titre"].lower() == titre.lower():
|
||||
return await interaction.response.send_message(
|
||||
f"❌ {assigne.mention} a déjà une tâche avec ce titre.", ephemeral=True)
|
||||
|
||||
try:
|
||||
date_obj = datetime.strptime(echeance, "%d/%m/%Y")
|
||||
date_str = date_obj.strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
return await interaction.response.send_message(
|
||||
"❌ Format de date invalide (JJ/MM/AAAA).", ephemeral=True)
|
||||
|
||||
id_tache = await ajouter_tache(titre, description, assigne.id, priorite, date_str, interaction.user.id)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"📌 Nouvelle Tâche #{id_tache}",
|
||||
description=description,
|
||||
color=discord.Color.blurple()
|
||||
)
|
||||
embed.set_author(name=f"Assignée à {assigne.display_name}", icon_url=assigne.display_avatar.url)
|
||||
embed.add_field(name="🗂 Titre", value=titre, inline=False)
|
||||
embed.add_field(name="⚠️ Priorité", value=priorite, inline=True)
|
||||
embed.add_field(name="📅 Échéance", value=echeance, inline=True)
|
||||
embed.set_footer(text=f"Créée par {interaction.user.display_name}", icon_url=interaction.user.display_avatar.url)
|
||||
|
||||
await interaction.response.send_message(embed=embed)
|
||||
|
||||
try:
|
||||
await assigne.send(
|
||||
f"👋 Bonjour {assigne.display_name},\n\nUne nouvelle tâche t'a été assignée par {interaction.user.mention} :",
|
||||
embed=embed
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@nouvelle_tache.autocomplete("priorite")
|
||||
async def autocomplete_priorite_handler(self, interaction: discord.Interaction, current: str):
|
||||
return await self.autocomplete_priorite(interaction, current)
|
||||
|
||||
@app_commands.command(name="mes_taches", description="Lister mes tâches assignées")
|
||||
async def mes_taches(self, interaction: discord.Interaction):
|
||||
taches = await lister_taches_utilisateur(interaction.user.id)
|
||||
if not taches:
|
||||
await interaction.response.send_message("🚫 Aucune tâche ne vous est assignée.", ephemeral=True)
|
||||
return
|
||||
|
||||
taches_list = list(taches.items())
|
||||
if len(taches_list) <= 5:
|
||||
embed = discord.Embed(title="📝 Vos Tâches Assignées", color=discord.Color.green())
|
||||
for tid, t in taches_list:
|
||||
embed.add_field(
|
||||
name=f"#{tid} - {t['titre']}",
|
||||
value=f"📅 Échéance : {t['échéance']}\n⚠️ Priorité : {t['priorité']}\n📌 Statut : {t['statut']}",
|
||||
inline=False
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
else:
|
||||
view = MesTachesPaginator(interaction, taches_list)
|
||||
await interaction.response.send_message(embed=view.get_embed(), view=view, ephemeral=True)
|
||||
|
||||
@app_commands.command(name="update_tache", description="Mettre à jour le statut et la priorité d'une tâche")
|
||||
@app_commands.describe(
|
||||
id_tache="ID de la tâche à mettre à jour",
|
||||
statut="Nouveau statut de la tâche",
|
||||
priorite="Nouvelle priorité (optionnel)"
|
||||
)
|
||||
async def update_tache(
|
||||
self, interaction: discord.Interaction, id_tache: str, statut: str, priorite: str = None
|
||||
):
|
||||
data = await charger_taches()
|
||||
if id_tache not in data.get("taches", {}):
|
||||
return await interaction.response.send_message("❌ Tâche non trouvée.", ephemeral=True)
|
||||
|
||||
tache = data["taches"][id_tache]
|
||||
if tache["assigné"] != interaction.user.id:
|
||||
return await interaction.response.send_message("❌ Vous ne pouvez pas mettre à jour le statut de cette tâche.", ephemeral=True)
|
||||
|
||||
statut_update = await update_tache_status(id_tache, statut)
|
||||
priorite_update = True
|
||||
if priorite:
|
||||
tache["priorité"] = priorite
|
||||
await sauvegarder_taches(data)
|
||||
|
||||
if statut_update and priorite_update:
|
||||
await interaction.response.send_message(
|
||||
f"✅ Le statut de la tâche #{id_tache} a été mis à jour à **{statut}**." +
|
||||
(f"\nLa priorité est maintenant **{priorite}**." if priorite else ""),
|
||||
ephemeral=True
|
||||
)
|
||||
if tache["créé_par"] != interaction.user.id:
|
||||
try:
|
||||
creator = await self.bot.fetch_user(tache["créé_par"])
|
||||
await creator.send(
|
||||
f"🔔 La tâche #{id_tache} que vous avez créée a été mise à jour par {interaction.user.mention} :\n"
|
||||
f"**Nouveau statut** : {statut}" +
|
||||
(f"\n**Nouvelle priorité** : {priorite}" if priorite else "")
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
await interaction.response.send_message("❌ Erreur lors de la mise à jour de la tâche.", ephemeral=True)
|
||||
|
||||
@update_tache.autocomplete("priorite")
|
||||
async def autocomplete_priorite_update(self, interaction: discord.Interaction, current: str):
|
||||
return await self.autocomplete_priorite(interaction, current)
|
||||
|
||||
@app_commands.command(name="supprimer_tache", description="Supprimer une tâche")
|
||||
@app_commands.describe(
|
||||
id_tache="ID de la tâche à supprimer"
|
||||
)
|
||||
async def supprimer_tache_cmd(self, interaction: discord.Interaction, id_tache: str):
|
||||
data = await charger_taches()
|
||||
if id_tache not in data.get("taches", {}):
|
||||
return await interaction.response.send_message("❌ Tâche non trouvée.", ephemeral=True)
|
||||
|
||||
tache = data["taches"][id_tache]
|
||||
perms = interaction.user.guild_permissions
|
||||
can_delete = (
|
||||
tache["créé_par"] == interaction.user.id or
|
||||
tache["assigné"] == interaction.user.id or
|
||||
perms.administrator
|
||||
)
|
||||
if not can_delete:
|
||||
return await interaction.response.send_message("❌ Vous ne pouvez pas supprimer cette tâche.", ephemeral=True)
|
||||
|
||||
success = await supprimer_tache(id_tache)
|
||||
if success:
|
||||
await interaction.response.send_message(f"🗑️ La tâche #{id_tache} a été supprimée.", ephemeral=True)
|
||||
else:
|
||||
await interaction.response.send_message("❌ Erreur lors de la suppression de la tâche.", ephemeral=True)
|
||||
|
||||
@tasks.loop(seconds=30)
|
||||
async def rappels(self):
|
||||
now = datetime.now(ZoneInfo("Europe/Paris"))
|
||||
if now.hour == 9 and now.minute == 0:
|
||||
taches = await taches_proches()
|
||||
for tid, t in taches.items():
|
||||
try:
|
||||
user = await self.bot.fetch_user(t["assigné"])
|
||||
embed = discord.Embed(
|
||||
title=f"🔔 Rappel Tâche #{tid}",
|
||||
description=t["description"],
|
||||
color=discord.Color.orange()
|
||||
)
|
||||
embed.add_field(name="🗂 Titre", value=t["titre"], inline=False)
|
||||
embed.add_field(name="⚠️ Priorité", value=t["priorité"], inline=True)
|
||||
embed.add_field(name="📅 Échéance", value=t["échéance"], inline=True)
|
||||
embed.set_footer(text="Clique sur le bouton si tu as fini !")
|
||||
view = RappelTacheView(tid)
|
||||
await user.send(embed=embed, view=view)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
@rappels.before_loop
|
||||
async def avant_rappel(self):
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(JiraDiscord(bot))
|
||||
30
backups/scratch/check_button_sig.py
Normal file
30
backups/scratch/check_button_sig.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import disnake
|
||||
from disnake.ui import View, Button
|
||||
import asyncio
|
||||
|
||||
class MyView(View):
|
||||
def __init__(self):
|
||||
super().__init__(timeout=None)
|
||||
|
||||
@disnake.ui.button(label="Test")
|
||||
async def my_btn(self, arg1, arg2):
|
||||
print(f"Arg1 type: {type(arg1)}")
|
||||
print(f"Arg2 type: {type(arg2)}")
|
||||
|
||||
async def main():
|
||||
view = MyView()
|
||||
btn = view.children[0]
|
||||
print(f"Button: {btn}")
|
||||
# Mock an interaction
|
||||
class MockInteraction:
|
||||
pass
|
||||
|
||||
try:
|
||||
await btn.callback(MockInteraction())
|
||||
except Exception as e:
|
||||
print(f"Error during callback: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
4
backups/scratch/check_checks.py
Normal file
4
backups/scratch/check_checks.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import disnake
|
||||
import disnake.ext.commands as commands
|
||||
bot = commands.Bot(command_prefix="!")
|
||||
print(f"Check methods: {[m for m in dir(bot) if 'check' in m]}")
|
||||
16
backups/scratch/check_db.py
Normal file
16
backups/scratch/check_db.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import sqlite3
|
||||
import os
|
||||
|
||||
db_path = "config.db"
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
||||
tables = cursor.fetchall()
|
||||
print(f"Tables: {tables}")
|
||||
for table in tables:
|
||||
cursor.execute(f"PRAGMA table_info({table[0]});")
|
||||
print(f"Schema for {table[0]}: {cursor.fetchall()}")
|
||||
conn.close()
|
||||
else:
|
||||
print("Database not found.")
|
||||
19
backups/scratch/check_disnake.py
Normal file
19
backups/scratch/check_disnake.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import disnake
|
||||
import disnake.ext.commands as commands
|
||||
import asyncio
|
||||
|
||||
print(f"Disnake version: {disnake.__version__}")
|
||||
bot = commands.Bot(command_prefix="!")
|
||||
print(f"Bot has setup_hook: {hasattr(bot, 'setup_hook')}")
|
||||
|
||||
async def test():
|
||||
print("Testing setup_hook call...")
|
||||
class TestBot(commands.Bot):
|
||||
async def setup_hook(self):
|
||||
print("setup_hook CALLED!")
|
||||
|
||||
tbot = TestBot(command_prefix="!")
|
||||
# We won't start it because we don't have a token here,
|
||||
# but we can check if the library expects it.
|
||||
|
||||
asyncio.run(test())
|
||||
7
backups/scratch/check_disnake_2.py
Normal file
7
backups/scratch/check_disnake_2.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import disnake
|
||||
import disnake.ext.commands as commands
|
||||
import asyncio
|
||||
|
||||
bot = commands.Bot(command_prefix="!")
|
||||
print(f"Bot methods: {[m for m in dir(bot) if 'setup' in m or 'load' in m]}")
|
||||
print(f"Is context manager: {hasattr(bot, '__aenter__')}")
|
||||
6
backups/scratch/check_disnake_3.py
Normal file
6
backups/scratch/check_disnake_3.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import disnake
|
||||
import disnake.ext.commands as commands
|
||||
import inspect
|
||||
|
||||
bot = commands.Bot(command_prefix="!")
|
||||
print(f"load_extension is coroutine: {inspect.iscoroutinefunction(bot.load_extension)}")
|
||||
8
backups/scratch/check_textstyle.py
Normal file
8
backups/scratch/check_textstyle.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import disnake
|
||||
print(f"disnake.TextStyle: {hasattr(disnake, 'TextStyle')}")
|
||||
print(f"disnake.TextInputStyle: {hasattr(disnake, 'TextInputStyle')}")
|
||||
try:
|
||||
import disnake.ui
|
||||
print(f"disnake.ui.TextInputStyle: {hasattr(disnake.ui, 'TextInputStyle')}")
|
||||
except:
|
||||
pass
|
||||
52
backups/scratch/fix_modal_view_mixup.py
Normal file
52
backups/scratch/fix_modal_view_mixup.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import os
|
||||
import re
|
||||
|
||||
def fix_file(filepath):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Split by class to be surgical
|
||||
classes = re.split(r'^(class\s+.*)$', content, flags=re.MULTILINE)
|
||||
|
||||
new_content = []
|
||||
if len(classes) > 0:
|
||||
new_content.append(classes[0]) # Header
|
||||
|
||||
for i in range(1, len(classes), 2):
|
||||
class_header = classes[i]
|
||||
class_body = classes[i+1] if i+1 < len(classes) else ""
|
||||
|
||||
# Determine if it's a View or a Modal
|
||||
is_view = "disnake.ui.View" in class_header or "(disnake.ui.View)" in class_header or "super().__init__(timeout=" in class_body
|
||||
is_modal = "disnake.ui.Modal" in class_header or "(disnake.ui.Modal)" in class_header or "super().__init__(title=" in class_body
|
||||
|
||||
if is_view:
|
||||
# Views use add_item
|
||||
class_body = class_body.replace("self.append_component(", "self.add_item(")
|
||||
# Ensure super().__init__ doesn't have components=[] if it's a view
|
||||
class_body = class_body.replace("super().__init__(components=[],", "super().__init__(")
|
||||
class_body = class_body.replace("super().__init__(components=[], ", "super().__init__(")
|
||||
|
||||
if is_modal:
|
||||
# Modals use append_component
|
||||
class_body = class_body.replace("self.add_item(", "self.append_component(")
|
||||
# Modals MUST have components=[] in super().__init__ if not already there
|
||||
# (My previous sed might have added it correctly)
|
||||
pass
|
||||
|
||||
new_content.append(class_header)
|
||||
new_content.append(class_body)
|
||||
|
||||
final_content = "".join(new_content)
|
||||
if final_content != content:
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(final_content)
|
||||
return True
|
||||
return False
|
||||
|
||||
for root, dirs, files in os.walk('commandes'):
|
||||
for file in files:
|
||||
if file.endswith('.py'):
|
||||
path = os.path.join(root, file)
|
||||
if fix_file(path):
|
||||
print(f"Fixed {path}")
|
||||
36
backups/scratch/fix_text_input_default.py
Normal file
36
backups/scratch/fix_text_input_default.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import os
|
||||
import re
|
||||
|
||||
def fix_text_input_default(filepath):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Match TextInput( followed by anything including newlines, until )
|
||||
# This is a bit risky but we can refine it.
|
||||
# We look for default= inside the arguments of TextInput
|
||||
|
||||
def replace_default(match):
|
||||
args = match.group(2)
|
||||
new_args = args.replace("default=", "value=")
|
||||
return f"{match.group(1)}{new_args})"
|
||||
|
||||
# Regex: (disnake\.ui\.TextInput\(|ui\.TextInput\(|TextInput\() (.*? \))
|
||||
# We need to handle nested parentheses if any, but TextInput usually doesn't have them in args.
|
||||
|
||||
new_content = re.sub(r'((?:disnake\.ui\.|ui\.|)TextInput\()(.*?\))', replace_default, content, flags=re.DOTALL)
|
||||
|
||||
if new_content != content:
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
return True
|
||||
return False
|
||||
|
||||
files_to_check = [
|
||||
"commandes/moderation.py",
|
||||
"commandes/ticket/__init__.py"
|
||||
]
|
||||
|
||||
for path in files_to_check:
|
||||
if os.path.exists(path):
|
||||
if fix_text_input_default(path):
|
||||
print(f"Fixed {path}")
|
||||
7
backups/scratch/inspect_modal.py
Normal file
7
backups/scratch/inspect_modal.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import disnake
|
||||
import inspect
|
||||
|
||||
try:
|
||||
print(f"Modal init signature: {inspect.signature(disnake.ui.Modal.__init__)}")
|
||||
except Exception as e:
|
||||
print(f"Error inspecting Modal: {e}")
|
||||
7
backups/scratch/inspect_section.py
Normal file
7
backups/scratch/inspect_section.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import disnake
|
||||
import inspect
|
||||
|
||||
try:
|
||||
print(f"Section init signature: {inspect.signature(disnake.ui.Section.__init__)}")
|
||||
except Exception as e:
|
||||
print(f"Error inspecting Section: {e}")
|
||||
3
backups/scratch/test_container.py
Normal file
3
backups/scratch/test_container.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import disnake
|
||||
print(disnake.ui.Container)
|
||||
print(disnake.ui.TextDisplay)
|
||||
17
backups/scratch/test_edit_message.py
Normal file
17
backups/scratch/test_edit_message.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import disnake
|
||||
import asyncio
|
||||
|
||||
async def main():
|
||||
class DummyResponse:
|
||||
async def edit_message(self, *args, **kwargs):
|
||||
print(kwargs)
|
||||
|
||||
inter = type('DummyInter', (), {'response': DummyResponse()})()
|
||||
container = disnake.ui.Container()
|
||||
try:
|
||||
await inter.response.edit_message(components=[container])
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
asyncio.run(main())
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 195 KiB |
BIN
banners/banner_1370362952938688573.png
Normal file
BIN
banners/banner_1370362952938688573.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 345 KiB |
BIN
banners/banner_1402491843899555840.png
Normal file
BIN
banners/banner_1402491843899555840.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 328 KiB |
BIN
banners/banner_goodbye_1015968420203855883.png
Normal file
BIN
banners/banner_goodbye_1015968420203855883.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
251
bot.py
251
bot.py
|
|
@ -1,18 +1,74 @@
|
|||
import os
|
||||
# Nettoyer les variables de certificats SSL corrompues ou obsolètes pour éviter les crashs TLS
|
||||
os.environ.pop("REQUESTS_CA_BUNDLE", None)
|
||||
os.environ.pop("SSL_CERT_FILE", None)
|
||||
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Charger les variables d'environnement AVANT tout le reste
|
||||
load_dotenv()
|
||||
|
||||
import discord
|
||||
import disnake
|
||||
from commandes.ticket.utils.permissions import can_access_config
|
||||
from commandes.ticket.data.storage import get_storage
|
||||
from discord.ext import commands
|
||||
from disnake.ext import commands
|
||||
import asyncio
|
||||
from src.logger import kuby_logger, log_performance
|
||||
import logging
|
||||
from src.advanced_logger import AdvancedLogger
|
||||
|
||||
SENSITIVE_PERMISSIONS = {
|
||||
"administrator", "manage_guild", "manage_roles", "manage_channels",
|
||||
"kick_members", "ban_members", "manage_messages", "manage_webhooks",
|
||||
"moderate_members"
|
||||
}
|
||||
|
||||
class RoleApprovalView(disnake.ui.View):
|
||||
def __init__(self, member: disnake.Member, roles: list, owner: disnake.Member):
|
||||
super().__init__(timeout=86400) # 24 hours
|
||||
self.member = member
|
||||
self.roles = roles
|
||||
self.owner = owner
|
||||
|
||||
@disnake.ui.button(label="Accepter", style=disnake.ButtonStyle.green, emoji="✅")
|
||||
async def accept(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction):
|
||||
guild = self.member.guild
|
||||
current_member = guild.get_member(self.member.id)
|
||||
if not current_member:
|
||||
await interaction.response.send_message("❌ L'utilisateur n'est plus sur le serveur.", ephemeral=True)
|
||||
self.stop()
|
||||
return
|
||||
|
||||
try:
|
||||
await current_member.add_roles(*self.roles, reason="Restauration sécurisée approuvée par le propriétaire")
|
||||
await interaction.response.send_message(f"✅ Rôles sensibles restaurés pour {self.member.mention}.", ephemeral=True)
|
||||
|
||||
components = [
|
||||
disnake.ui.Container(
|
||||
disnake.ui.TextDisplay(f"✅ Vous avez approuvé la restauration des rôles pour {self.member.mention}."),
|
||||
disnake.ui.TextDisplay(f"🛡️ Rôles : {', '.join([r.name for r in self.roles])}")
|
||||
)
|
||||
]
|
||||
await interaction.edit_original_response(embed=None, components=components, view=None)
|
||||
self.stop()
|
||||
except Exception as e:
|
||||
await interaction.response.send_message(f"❌ Erreur lors de l'attribution : {e}", ephemeral=True)
|
||||
|
||||
@disnake.ui.button(label="Refuser", style=disnake.ButtonStyle.red, emoji="✖️")
|
||||
async def deny(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction):
|
||||
await interaction.response.send_message(f"❌ Vous avez refusé la restauration pour {self.member.mention}.", ephemeral=True)
|
||||
|
||||
components = [
|
||||
disnake.ui.Container(
|
||||
disnake.ui.TextDisplay(f"❌ Vous avez refusé la restauration des rôles pour {self.member.mention}."),
|
||||
disnake.ui.TextDisplay(f"🛡️ Rôles : {', '.join([r.name for r in self.roles])}")
|
||||
)
|
||||
]
|
||||
await interaction.edit_original_response(embed=None, components=components, view=None)
|
||||
self.stop()
|
||||
|
||||
|
||||
# Récupération et validation de l'ID d'application
|
||||
application_id_raw = os.getenv("APPLICATION_ID")
|
||||
kuby_logger.debug(f"Raw APPLICATION_ID: {application_id_raw}")
|
||||
|
|
@ -23,12 +79,13 @@ except ValueError:
|
|||
kuby_logger.error(f"❌ APPLICATION_ID invalide : {application_id_raw}")
|
||||
application_id = None
|
||||
|
||||
# Configuration des intents Discord
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
intents.members = True
|
||||
# Configuration des intents Disnake
|
||||
intents = disnake.Intents.default()
|
||||
intents.message_content = True
|
||||
intents.members = True
|
||||
intents.voice_states = True
|
||||
kuby_logger.debug(f"Discord intents configured: {intents}")
|
||||
kuby_logger.debug(f"Disnake intents configured: {intents}")
|
||||
|
||||
|
||||
class MyBot(commands.Bot):
|
||||
def __init__(self):
|
||||
|
|
@ -37,17 +94,19 @@ class MyBot(commands.Bot):
|
|||
command_prefix="!",
|
||||
intents=intents,
|
||||
application_id=application_id,
|
||||
command_sync_flags=commands.CommandSyncFlags.all()
|
||||
)
|
||||
kuby_logger.info("MyBot instance created successfully")
|
||||
|
||||
async def setup_hook(self):
|
||||
kuby_logger.info("🚀 Démarrage du bot Kuby...")
|
||||
|
||||
# Initialize advanced logger
|
||||
self.advanced_logger = AdvancedLogger(self)
|
||||
kuby_logger.info("✅ Advanced logger initialized")
|
||||
|
||||
# Charger les extensions
|
||||
# Flask est géré par PM2 (kuby-flask), pas besoin de le lancer ici
|
||||
|
||||
async def do_async_setup(self):
|
||||
kuby_logger.info("🚀 Configuration initiale du bot Kuby...")
|
||||
|
||||
extensions = [
|
||||
"commandes.whitelist",
|
||||
"commandes.whitelist_monitor",
|
||||
|
|
@ -65,78 +124,40 @@ class MyBot(commands.Bot):
|
|||
"commandes.modules_security.logs_manager",
|
||||
"commandes.modules_security.rules",
|
||||
"commandes.ticket_whitelist",
|
||||
"commandes.bug_report"
|
||||
"commandes.bug_report",
|
||||
"commandes.convocation",
|
||||
"commandes.absence_staff",
|
||||
"commandes.moderation",
|
||||
"commandes.invites",
|
||||
"commandes.taches",
|
||||
"commandes.rapports",
|
||||
]
|
||||
|
||||
for extension in extensions:
|
||||
try:
|
||||
await self.load_extension(extension)
|
||||
self.load_extension(extension)
|
||||
kuby_logger.info(f"✅ Extension chargée : {extension}")
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"❌ Erreur lors du chargement de {extension} : {e}", exc_info=True)
|
||||
|
||||
# --- SYSTÈME DE WHITELIST (CORRIGÉ POUR ÉVITER LES ERREURS) ---
|
||||
@self.check
|
||||
async def whitelist_check(ctx):
|
||||
try:
|
||||
# 1. Bypass pour le propriétaire (Owner)
|
||||
if await self.is_owner(ctx.author):
|
||||
return True
|
||||
# Synchroniser les commandes slash
|
||||
await self._sync_application_commands()
|
||||
kuby_logger.info("✅ Commandes slash synchronisées avec Discord")
|
||||
|
||||
# 1b. Bypass pour les commandes de config tickets
|
||||
# Si la commande est une commande ticket et l'utilisateur a les perms ticket
|
||||
if ctx.command and (ctx.command.name in ["ticketconfig", "ticketsetup", "configticket"]):
|
||||
try:
|
||||
storage = get_storage()
|
||||
config = storage.load_config(ctx.guild.id)
|
||||
if can_access_config(ctx, config):
|
||||
return True
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Error checking ticket permissions in global whitelist: {e}")
|
||||
# Fallthrough to normal whitelist check if error
|
||||
# --- LOGGING DES INTERACTIONS ---
|
||||
@self.listen("on_interaction")
|
||||
async def log_interaction(inter: disnake.Interaction):
|
||||
if inter.type == disnake.InteractionType.application_command:
|
||||
kuby_logger.info(f"⚡ Interaction reçue : /{inter.data.name} par {inter.user}")
|
||||
|
||||
|
||||
# 2. Récupération du Cog
|
||||
whitelist_cog = self.get_cog("WhitelistMonitor")
|
||||
if not whitelist_cog:
|
||||
kuby_logger.warning("WhitelistMonitor cog not found, allowing all commands")
|
||||
return True
|
||||
|
||||
is_whitelisted = whitelist_cog.is_whitelisted(ctx.guild.id, ctx.author.id)
|
||||
|
||||
if not is_whitelisted:
|
||||
kuby_logger.info(f"User {ctx.author} (ID: {ctx.author.id}) attempted to use command but is not whitelisted")
|
||||
|
||||
# Réponse adaptée (Slash vs Message)
|
||||
msg = "❌ Vous n'êtes pas autorisé à utiliser cette commande. Veuillez demander à être ajouté à la whitelist."
|
||||
if ctx.interaction:
|
||||
await ctx.interaction.response.send_message(msg, ephemeral=True)
|
||||
else:
|
||||
await ctx.send(msg)
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Error in whitelist check: {e}")
|
||||
return True
|
||||
|
||||
# Sync slash commands
|
||||
try:
|
||||
await self.tree.sync()
|
||||
kuby_logger.info("✅ Commandes slash synchronisées avec Discord")
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"❌ Erreur lors de la synchronisation des commandes slash : {e}", exc_info=True)
|
||||
|
||||
# --- ÉVÉNEMENTS RESTAURÉS ---
|
||||
kuby_logger.info("✅ Configuration terminée")
|
||||
|
||||
async def on_ready(self):
|
||||
kuby_logger.info(f"🤖 Bot connecté : {self.user} (ID : {self.user.id})")
|
||||
kuby_logger.info(f"📊 Serveurs : {len(self.guilds)}")
|
||||
for guild in self.guilds:
|
||||
kuby_logger.debug(f"Guild: {guild.name} (ID: {guild.id}) - Members: {guild.member_count}")
|
||||
|
||||
total_members = sum(guild.member_count for guild in self.guilds)
|
||||
kuby_logger.info(f"📊 Total members across all guilds: {total_members}")
|
||||
kuby_logger.info(f"🔍 Intents Status - Members: {self.intents.members}, Presences: {self.intents.presences}, Core: {self.intents.message_content}")
|
||||
if not self.intents.members:
|
||||
kuby_logger.warning("⚠️ L'intent MEMBERS est désactivé !")
|
||||
kuby_logger.info("✅ Bot prêt et opérationnel !")
|
||||
|
||||
async def on_guild_join(self, guild):
|
||||
|
|
@ -151,23 +172,83 @@ class MyBot(commands.Bot):
|
|||
kuby_logger.info(f"👋 {member} joined {member.guild.name}")
|
||||
if hasattr(self, 'advanced_logger'):
|
||||
self.advanced_logger.log_member_join(member)
|
||||
|
||||
# Vérifier si la restauration des rôles est activée pour ce serveur
|
||||
from commandes.security import load_settings
|
||||
settings = load_settings(member.guild.id)
|
||||
role_restore_enabled = settings.get("role_restore_enabled", True)
|
||||
|
||||
if not role_restore_enabled:
|
||||
kuby_logger.debug(f"Restauration des rôles désactivée pour {member.guild.name}, saut...")
|
||||
return
|
||||
|
||||
previous_roles = self.advanced_logger.check_previous_roles(member.id)
|
||||
if previous_roles:
|
||||
guild = member.guild
|
||||
roles_to_add = [guild.get_role(rid) for rid in previous_roles.get("role_ids", [])]
|
||||
roles_to_add = [r for r in roles_to_add if r and r < guild.me.top_role]
|
||||
if roles_to_add:
|
||||
try:
|
||||
await member.add_roles(*roles_to_add)
|
||||
kuby_logger.info(f"Restored {len(roles_to_add)} roles for {member}")
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Failed to restore roles: {e}")
|
||||
safe_roles = []
|
||||
sensitive_roles = []
|
||||
|
||||
for role in roles_to_add:
|
||||
is_sensitive = any(getattr(role.permissions, perm, False) for perm in SENSITIVE_PERMISSIONS)
|
||||
if is_sensitive:
|
||||
sensitive_roles.append(role)
|
||||
else:
|
||||
safe_roles.append(role)
|
||||
|
||||
if safe_roles:
|
||||
retries = 3
|
||||
for i in range(retries):
|
||||
try:
|
||||
await member.add_roles(*safe_roles)
|
||||
kuby_logger.info(f"Restored {len(safe_roles)} safe roles for {member}")
|
||||
break
|
||||
except (disnake.HTTPException, disnake.GatewayNotFound, asyncio.TimeoutError) as e:
|
||||
is_transient = isinstance(e, disnake.HTTPException) and e.status in [500, 502, 503, 504]
|
||||
if not isinstance(e, disnake.HTTPException):
|
||||
is_transient = True
|
||||
# 403 with code 50013 = Missing Permissions - don't retry
|
||||
if isinstance(e, disnake.HTTPException) and e.status == 403:
|
||||
error_code = getattr(e, 'code', None)
|
||||
# 50013 = Missing Permissions, 20023 = Cannot modify a role higher than bot's highest role
|
||||
if error_code in (50013, 20023):
|
||||
kuby_logger.warning(f"Missing Manage Roles permission to restore safe roles for {member} in {member.guild.name} - please ensure the bot has the Manage Roles permission")
|
||||
break
|
||||
if is_transient and i < retries - 1:
|
||||
await asyncio.sleep((i + 1) * 2)
|
||||
continue
|
||||
kuby_logger.error(f"Failed to restore safe roles for {member}: {e}")
|
||||
break
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Error restoring safe roles for {member}: {e}")
|
||||
break
|
||||
|
||||
if sensitive_roles:
|
||||
try:
|
||||
owner = guild.owner or await guild.fetch_member(guild.owner_id)
|
||||
embed = disnake.Embed(
|
||||
title="⚠️ Restauration de rôles sensibles",
|
||||
description=(
|
||||
f"L'utilisateur **{member}** ({member.id}) vient de rejoindre **{guild.name}**.\n\n"
|
||||
"Certains de ses anciens rôles possèdent des permissions sensibles :\n"
|
||||
f"🛡️ **Rôles en attente :** {', '.join([r.mention for r in sensitive_roles])}\n\n"
|
||||
"Voulez-vous restaurer ces rôles ?"
|
||||
),
|
||||
color=disnake.Color.yellow()
|
||||
)
|
||||
embed.set_thumbnail(url=member.display_avatar.url)
|
||||
view = RoleApprovalView(member, sensitive_roles, owner)
|
||||
await owner.send(embed=embed, view=view)
|
||||
kuby_logger.info(f"Sent role approval request for {member} to owner {owner}")
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Failed to send role approval request to owner: {e}")
|
||||
|
||||
async def on_member_remove(self, member):
|
||||
kuby_logger.info(f"👋 {member} left {member.guild.name}")
|
||||
if hasattr(self, 'advanced_logger'):
|
||||
self.advanced_logger.log_member_leave(member)
|
||||
# Notification de départ
|
||||
try:
|
||||
from commandes.security import load_settings
|
||||
settings = load_settings(member.guild.id)
|
||||
|
|
@ -180,19 +261,23 @@ class MyBot(commands.Bot):
|
|||
kuby_logger.error(f"Error in leave notification: {e}")
|
||||
|
||||
async def on_message_delete(self, message):
|
||||
if message.author.bot: return
|
||||
if message.author.bot:
|
||||
return
|
||||
if hasattr(self, 'advanced_logger'):
|
||||
self.advanced_logger.log_message_event(message, "deleted")
|
||||
|
||||
async def on_message_edit(self, before, after):
|
||||
if before.author.bot or before.content == after.content: return
|
||||
if before.author.bot or before.content == after.content:
|
||||
return
|
||||
if hasattr(self, 'advanced_logger'):
|
||||
self.advanced_logger.log_message_event(before, "edited", {
|
||||
"before_content": before.content, "after_content": after.content
|
||||
"before_content": before.content,
|
||||
"after_content": after.content
|
||||
})
|
||||
|
||||
async def on_voice_state_update(self, member, before, after):
|
||||
if member.bot: return
|
||||
if member.bot:
|
||||
return
|
||||
if before.channel != after.channel and hasattr(self, 'advanced_logger'):
|
||||
if before.channel is None:
|
||||
self.advanced_logger.log_voice_event(member, after.channel, "joined")
|
||||
|
|
@ -203,8 +288,8 @@ class MyBot(commands.Bot):
|
|||
self.advanced_logger.log_voice_event(member, after.channel, "moved_to")
|
||||
|
||||
async def on_command_error(self, ctx, error):
|
||||
if isinstance(error, commands.CommandNotFound):
|
||||
return
|
||||
if isinstance(error, commands.CheckFailure):
|
||||
return # Déjà géré dans le whitelist_check
|
||||
kuby_logger.error(f"Command error in {ctx.command}: {str(error)}", exc_info=True)
|
||||
|
||||
bot = MyBot()
|
||||
return
|
||||
kuby_logger.error(f"Command error in {ctx.command}: {str(error)}", exc_info=True)
|
||||
18
check_cols.py
Normal file
18
check_cols.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import sqlite3
|
||||
import os
|
||||
|
||||
db_path = "/home/gameurpro12/Documents/Mes_projets/Discord_bot/backup_kuby/config.db"
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute("SELECT * FROM server_configs LIMIT 1")
|
||||
col_names = [description[0] for description in cursor.description]
|
||||
print(f"Columns: {col_names}")
|
||||
row = cursor.fetchone()
|
||||
print(f"Data: {row}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
conn.close()
|
||||
else:
|
||||
print("Database not found")
|
||||
18
check_db.py
Normal file
18
check_db.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import sqlite3
|
||||
import os
|
||||
|
||||
db_path = "/home/gameurpro12/Documents/Mes_projets/Discord_bot/backup_kuby/config.db"
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute("SELECT * FROM server_configs")
|
||||
rows = cursor.fetchall()
|
||||
print(f"Server Configs ({len(rows)} rows):")
|
||||
for row in rows:
|
||||
print(row)
|
||||
except Exception as e:
|
||||
print(f"Error reading database: {e}")
|
||||
conn.close()
|
||||
else:
|
||||
print("Database not found")
|
||||
18
check_schema.py
Normal file
18
check_schema.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import sqlite3
|
||||
import os
|
||||
|
||||
db_path = "/home/gameurpro12/Documents/Mes_projets/Discord_bot/backup_kuby/config.db"
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute("PRAGMA table_info(server_configs)")
|
||||
rows = cursor.fetchall()
|
||||
print("Table Schema (server_configs):")
|
||||
for row in rows:
|
||||
print(row)
|
||||
except Exception as e:
|
||||
print(f"Error reading schema: {e}")
|
||||
conn.close()
|
||||
else:
|
||||
print("Database not found")
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
from disnake.ext import commands
|
||||
import json
|
||||
import os
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ class Blacklist(commands.Cog):
|
|||
# ---------- Slash Commands ----------
|
||||
@app_commands.command(name="blacklist", description="Blacklist un utilisateur")
|
||||
@app_commands.describe(user="Utilisateur à blacklister", reason="Raison")
|
||||
async def blacklist_slash(self, interaction: discord.Interaction, user: discord.User, reason: str = "Aucune raison fournie"):
|
||||
async def blacklist_slash(self, interaction: disnake.Interaction, user: disnake.User, reason: str = "Aucune raison fournie"):
|
||||
if not self.is_agent(interaction.user.id):
|
||||
return await interaction.response.send_message("⛔ Tu n’as pas la permission.", ephemeral=True)
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ class Blacklist(commands.Cog):
|
|||
|
||||
@app_commands.command(name="unblacklist", description="Retire un utilisateur de la blacklist")
|
||||
@app_commands.describe(user="Utilisateur à retirer")
|
||||
async def unblacklist_slash(self, interaction: discord.Interaction, user: discord.User):
|
||||
async def unblacklist_slash(self, interaction: disnake.Interaction, user: disnake.User):
|
||||
if not self.is_agent(interaction.user.id):
|
||||
return await interaction.response.send_message("⛔ Tu n’as pas la permission.", ephemeral=True)
|
||||
|
||||
|
|
@ -81,13 +81,13 @@ class Blacklist(commands.Cog):
|
|||
pass
|
||||
|
||||
# --------- Boutons ----------
|
||||
class TicketButton(discord.ui.View):
|
||||
class TicketButton(disnake.ui.View):
|
||||
def __init__(self, user_id: int):
|
||||
super().__init__(timeout=None)
|
||||
self.user_id = user_id
|
||||
|
||||
@discord.ui.button(label="📩 Ouvrir une demande de déblacklist", style=discord.ButtonStyle.danger)
|
||||
async def open_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
@disnake.ui.button(label="📩 Ouvrir une demande de déblacklist", style=disnake.ButtonStyle.danger)
|
||||
async def open_ticket(self, button: disnake.ui.Button, interaction: disnake.Interaction):
|
||||
if interaction.user.id != self.user_id:
|
||||
return await interaction.response.send_message("❌ Ce bouton ne t’appartient pas.", ephemeral=True)
|
||||
|
||||
|
|
@ -110,13 +110,13 @@ class TicketButton(discord.ui.View):
|
|||
|
||||
await interaction.response.send_message("✅ Ton ticket a été créé en MP avec le bot.", ephemeral=True)
|
||||
|
||||
class CloseTicketView(discord.ui.View):
|
||||
class CloseTicketView(disnake.ui.View):
|
||||
def __init__(self, user_id: int):
|
||||
super().__init__(timeout=None)
|
||||
self.user_id = user_id
|
||||
|
||||
@discord.ui.button(label="❌ Fermer le ticket", style=discord.ButtonStyle.danger)
|
||||
async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
@disnake.ui.button(label="❌ Fermer le ticket", style=disnake.ButtonStyle.danger)
|
||||
async def close_ticket(self, button: disnake.ui.Button, interaction: disnake.Interaction):
|
||||
agents = load_json(AGENTS_FILE, {"agents": []})
|
||||
if str(interaction.user.id) not in agents["agents"]:
|
||||
return await interaction.response.send_message("⛔ Seuls les agents Omega Kube peuvent fermer un ticket.", ephemeral=True)
|
||||
|
|
@ -124,13 +124,13 @@ class CloseTicketView(discord.ui.View):
|
|||
view = ConfirmCloseView(self.user_id)
|
||||
await interaction.response.send_message("⚠️ Veux-tu vraiment fermer ce ticket ?", view=view, ephemeral=True)
|
||||
|
||||
class ConfirmCloseView(discord.ui.View):
|
||||
class ConfirmCloseView(disnake.ui.View):
|
||||
def __init__(self, user_id: int):
|
||||
super().__init__(timeout=30)
|
||||
self.user_id = user_id
|
||||
|
||||
@discord.ui.button(label="✅ Oui", style=discord.ButtonStyle.success)
|
||||
async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
@disnake.ui.button(label="✅ Oui", style=disnake.ButtonStyle.success)
|
||||
async def confirm(self, button: disnake.ui.Button, interaction: disnake.Interaction):
|
||||
agents = load_json(AGENTS_FILE, {"agents": []})
|
||||
if str(interaction.user.id) not in agents["agents"]:
|
||||
return await interaction.response.send_message("⛔ Tu n’as pas la permission.", ephemeral=True)
|
||||
|
|
@ -142,12 +142,12 @@ class ConfirmCloseView(discord.ui.View):
|
|||
await interaction.channel.send("✅ Ticket fermé.")
|
||||
await interaction.channel.delete()
|
||||
|
||||
@discord.ui.button(label="❌ Non", style=discord.ButtonStyle.secondary)
|
||||
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
@disnake.ui.button(label="❌ Non", style=disnake.ButtonStyle.secondary)
|
||||
async def cancel(self, button: disnake.ui.Button, interaction: disnake.Interaction):
|
||||
await interaction.response.send_message("❌ Fermeture annulée.", ephemeral=True)
|
||||
|
||||
# --------- Setup Cog ----------
|
||||
async def setup(bot: commands.Bot):
|
||||
def setup(bot: commands.Bot):
|
||||
cog = Blacklist(bot)
|
||||
await bot.add_cog(cog)
|
||||
await bot.tree.sync(guild=discord.Object(id=MAIN_GUILD_ID))
|
||||
await bot.tree.sync(guild=disnake.Object(id=MAIN_GUILD_ID))
|
||||
|
|
|
|||
555
commandes/absence_staff.py
Executable file
555
commandes/absence_staff.py
Executable file
|
|
@ -0,0 +1,555 @@
|
|||
import disnake
|
||||
from disnake.ext import commands, tasks
|
||||
import json
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
except ImportError:
|
||||
from backports.zoneinfo import ZoneInfo
|
||||
from typing import Optional, Dict, Any
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
PARIS_TZ = ZoneInfo("Europe/Paris")
|
||||
DATE_FORMAT = "%d/%m/%Y %H:%M"
|
||||
|
||||
# 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:
|
||||
"""Charge les paramètres d'absence pour un serveur"""
|
||||
if not os.path.exists(ABSENCE_SETTINGS_FILE):
|
||||
return {}
|
||||
try:
|
||||
with open(ABSENCE_SETTINGS_FILE, "r", encoding='utf-8') as f:
|
||||
return json.load(f).get(str(guild_id), {})
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
return {}
|
||||
|
||||
def save_settings(guild_id: int, settings: dict) -> None:
|
||||
"""Sauvegarde les paramètres d'absence"""
|
||||
data = {}
|
||||
if os.path.exists(ABSENCE_SETTINGS_FILE):
|
||||
try:
|
||||
with open(ABSENCE_SETTINGS_FILE, "r", encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
data[str(guild_id)] = settings
|
||||
os.makedirs(os.path.dirname(ABSENCE_SETTINGS_FILE), exist_ok=True)
|
||||
with open(ABSENCE_SETTINGS_FILE, "w", encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
async def load_absences() -> dict:
|
||||
"""Charge toutes les absences (tous serveurs confondus)"""
|
||||
async with file_lock:
|
||||
if not os.path.exists(ABSENCE_DATA_FILE):
|
||||
return {}
|
||||
try:
|
||||
with open(ABSENCE_DATA_FILE, "r", encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
return {}
|
||||
|
||||
async def save_absences(data: dict) -> None:
|
||||
"""Sauvegarde toutes les absences"""
|
||||
async with file_lock:
|
||||
os.makedirs(os.path.dirname(ABSENCE_DATA_FILE), exist_ok=True)
|
||||
temp_file = ABSENCE_DATA_FILE + ".tmp"
|
||||
with open(temp_file, "w", encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
os.replace(temp_file, ABSENCE_DATA_FILE)
|
||||
|
||||
def get_guild_absences(all_absences: dict, guild_id: int) -> dict:
|
||||
"""Filtre les absences pour un serveur spécifique"""
|
||||
return all_absences.get(str(guild_id), {})
|
||||
|
||||
def update_guild_absences(all_absences: dict, guild_id: int, guild_data: dict) -> dict:
|
||||
"""Met à jour les absences pour un serveur spécifique"""
|
||||
all_absences[str(guild_id)] = guild_data
|
||||
return all_absences
|
||||
|
||||
def get_paris_now():
|
||||
"""Récupère l'heure actuelle à Paris"""
|
||||
return datetime.now(PARIS_TZ)
|
||||
|
||||
def parse_date(date_str: Optional[str]):
|
||||
"""Parse une date au format JJ/MM/AAAA HH:MM en timezone Paris"""
|
||||
if not date_str:
|
||||
raise ValueError("Date vide")
|
||||
return datetime.strptime(date_str, DATE_FORMAT).replace(tzinfo=PARIS_TZ)
|
||||
|
||||
# --- MODALS & VIEWS ---
|
||||
|
||||
class AbsenceModal(disnake.ui.Modal):
|
||||
def __init__(self, superieur: Optional[disnake.Member] = None):
|
||||
self.superieur = superieur
|
||||
self.motif_input = disnake.ui.TextInput(
|
||||
label="Motif de l'absence",
|
||||
style=disnake.TextInputStyle.paragraph,
|
||||
placeholder="Ex: Vacances, Raisons personnelles, Travail...",
|
||||
required=True,
|
||||
max_length=500,
|
||||
custom_id="absence_motif"
|
||||
)
|
||||
self.debut_input = disnake.ui.TextInput(
|
||||
label="Début (JJ/MM/AAAA HH:MM)",
|
||||
placeholder="Ex: 15/05/2024 08:00",
|
||||
min_length=16,
|
||||
max_length=16,
|
||||
required=True,
|
||||
custom_id="absence_debut"
|
||||
)
|
||||
self.fin_input = disnake.ui.TextInput(
|
||||
label="Fin (JJ/MM/AAAA HH:MM)",
|
||||
placeholder="Ex: 20/05/2024 18:00",
|
||||
min_length=16,
|
||||
max_length=16,
|
||||
required=True,
|
||||
custom_id="absence_fin"
|
||||
)
|
||||
super().__init__(title="Déclarer une Absence Staff", components=[self.motif_input, self.debut_input, self.fin_input])
|
||||
|
||||
async def callback(self, interaction: disnake.ModalInteraction):
|
||||
try:
|
||||
val_debut = interaction.text_values["absence_debut"]
|
||||
val_fin = interaction.text_values["absence_fin"]
|
||||
motif = interaction.text_values["absence_motif"]
|
||||
|
||||
debut = parse_date(val_debut)
|
||||
fin = parse_date(val_fin)
|
||||
except (ValueError, TypeError, KeyError) as e:
|
||||
return await interaction.response.send_message(f"❌ Format de date invalide ou champ manquant. Utilise le format: `{DATE_FORMAT.replace('%d', 'JJ').replace('%m', 'MM').replace('%Y', 'AAAA').replace('%H', 'HH').replace('%M', 'MM')}`", ephemeral=True)
|
||||
|
||||
if debut > fin:
|
||||
return await interaction.response.send_message("❌ La date de début doit précéder la date de fin.", ephemeral=True)
|
||||
|
||||
# Vérification si déjà absent
|
||||
all_abs = await load_absences()
|
||||
guild_abs = get_guild_absences(all_abs, interaction.guild.id)
|
||||
|
||||
if str(interaction.user.id) in guild_abs:
|
||||
return await interaction.response.send_message("❌ Tu as déjà déclaré une absence sur ce serveur.", ephemeral=True)
|
||||
|
||||
settings = load_settings(interaction.guild.id)
|
||||
channel_id = settings.get("absence_channel_id")
|
||||
|
||||
if not channel_id:
|
||||
return await interaction.response.send_message("❌ Le salon d'absence n'est pas configuré sur ce serveur. Contacte un administrateur.", ephemeral=True)
|
||||
|
||||
channel = interaction.guild.get_channel(int(channel_id))
|
||||
if not channel:
|
||||
return await interaction.response.send_message("❌ Salon d'absence introuvable. Vérifie la configuration.", ephemeral=True)
|
||||
|
||||
duree_delta = fin - debut
|
||||
duree_jours = duree_delta.days
|
||||
duree_heures = duree_delta.seconds // 3600
|
||||
|
||||
duree_str = f"{duree_jours}j {duree_heures}h" if duree_jours > 0 else f"{duree_heures}h"
|
||||
|
||||
now = get_paris_now()
|
||||
etat = "à venir" if debut > now else "en cours"
|
||||
|
||||
# Message dans le salon avec V2 Components
|
||||
components = [
|
||||
disnake.ui.Container(
|
||||
disnake.ui.Section(
|
||||
f"# ABSENCE STAFF\n**👤 Membre** : {interaction.user.mention}",
|
||||
accessory=disnake.ui.Thumbnail(interaction.user.display_avatar.url)
|
||||
),
|
||||
disnake.ui.Separator(),
|
||||
disnake.ui.TextDisplay(f"**⏳ Durée** : {duree_str}"),
|
||||
disnake.ui.TextDisplay(f"**📅 Période** : `{val_debut}` ➔ `{val_fin}`"),
|
||||
disnake.ui.TextDisplay(f"**📝 Motif** : {motif}"),
|
||||
disnake.ui.TextDisplay(f"**🛡️ Supérieur** : {self.superieur.mention if self.superieur else 'Aucun'}")
|
||||
)
|
||||
]
|
||||
absence_message = await channel.send(components=components)
|
||||
|
||||
# Enregistrement
|
||||
guild_abs[str(interaction.user.id)] = {
|
||||
"pseudo": interaction.user.name,
|
||||
"avatar_url": interaction.user.display_avatar.url,
|
||||
"debut": val_debut,
|
||||
"fin": val_fin,
|
||||
"duree": duree_str,
|
||||
"motif": motif,
|
||||
"superieur": self.superieur.mention if self.superieur else "Aucun",
|
||||
"message_id": absence_message.id,
|
||||
"etat": etat
|
||||
}
|
||||
|
||||
all_abs = update_guild_absences(all_abs, interaction.guild.id, guild_abs)
|
||||
await save_absences(all_abs)
|
||||
|
||||
# Attribution du rôle si déjà en cours
|
||||
if etat == "en cours":
|
||||
role_id = settings.get("absence_role_id")
|
||||
if role_id:
|
||||
role = interaction.guild.get_role(int(role_id))
|
||||
if role:
|
||||
try:
|
||||
await interaction.user.add_roles(role, reason="Début d'absence staff")
|
||||
except: pass
|
||||
|
||||
await interaction.response.send_message("✅ Absence enregistrée avec succès !", ephemeral=True)
|
||||
|
||||
class AbsenceConfigView(disnake.ui.View):
|
||||
def __init__(self, guild_id: int, bot):
|
||||
super().__init__(timeout=300)
|
||||
self.guild_id = guild_id
|
||||
self.bot = bot
|
||||
|
||||
@disnake.ui.channel_select(placeholder="Sélectionnez le salon d'affichage", min_values=1, max_values=1)
|
||||
async def select_channel(self, select: disnake.ui.ChannelSelect, interaction: disnake.MessageInteraction):
|
||||
channel = select.values[0]
|
||||
settings = load_settings(self.guild_id)
|
||||
settings["absence_channel_id"] = channel.id
|
||||
save_settings(self.guild_id, settings)
|
||||
|
||||
# Update the original message with new settings
|
||||
embed = self._get_updated_embed(settings, interaction.guild)
|
||||
await interaction.response.edit_message(embed=embed, view=self)
|
||||
await interaction.followup.send(f"✅ Salon d'absence configuré sur {channel.mention}", ephemeral=True)
|
||||
|
||||
@disnake.ui.role_select(placeholder="Sélectionnez le rôle d'absence", min_values=1, max_values=1)
|
||||
async def select_role(self, select: disnake.ui.RoleSelect, interaction: disnake.MessageInteraction):
|
||||
role = select.values[0]
|
||||
settings = load_settings(self.guild_id)
|
||||
settings["absence_role_id"] = role.id
|
||||
save_settings(self.guild_id, settings)
|
||||
|
||||
# Update the original message with new settings
|
||||
embed = self._get_updated_embed(settings, interaction.guild)
|
||||
await interaction.response.edit_message(embed=embed, view=self)
|
||||
await interaction.followup.send(f"✅ Rôle d'absence configuré sur **{role.name}**", ephemeral=True)
|
||||
|
||||
def _get_updated_embed(self, settings, guild):
|
||||
channel_id = settings.get("absence_channel_id")
|
||||
role_id = settings.get("absence_role_id")
|
||||
|
||||
embed = disnake.Embed(
|
||||
title="⚙️ Configuration des Absences Staff",
|
||||
description="Utilisez les menus déroulants ci-dessous pour configurer le système.",
|
||||
color=disnake.Color.blue()
|
||||
)
|
||||
embed.add_field(
|
||||
name="📁 Salon d'affichage",
|
||||
value=f"<#{channel_id}>" if channel_id else "❌ Non configuré",
|
||||
inline=True
|
||||
)
|
||||
embed.add_field(
|
||||
name="🎭 Rôle d'absence",
|
||||
value=f"<@&{role_id}>" if role_id else "❌ Non configuré",
|
||||
inline=True
|
||||
)
|
||||
return embed
|
||||
|
||||
class Absence(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.check_absences.start()
|
||||
|
||||
@commands.slash_command(name="absence", description="Déclarer une absence staff")
|
||||
@check_premium_tier()
|
||||
async def absence(self, interaction: disnake.ApplicationCommandInteraction,
|
||||
superieur: Optional[disnake.Member] = commands.Param(None, description="Le supérieur hiérarchique à prévenir")):
|
||||
"""Déclare une absence via un formulaire"""
|
||||
await interaction.response.send_modal(AbsenceModal(superieur))
|
||||
|
||||
@commands.slash_command(name="absence_config", description="Configurer le système d'absence (Admin)")
|
||||
@check_premium_tier()
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def absence_config(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
"""Ouvre le panneau de configuration des absences"""
|
||||
settings = load_settings(interaction.guild.id)
|
||||
|
||||
embed = disnake.Embed(
|
||||
title="⚙️ Configuration des Absences Staff",
|
||||
description="Utilisez les boutons ci-dessous pour configurer le système.",
|
||||
color=disnake.Color.blue()
|
||||
)
|
||||
|
||||
channel_id = settings.get("absence_channel_id")
|
||||
role_id = settings.get("absence_role_id")
|
||||
|
||||
embed.add_field(
|
||||
name="📁 Salon d'affichage",
|
||||
value=f"<#{channel_id}>" if channel_id else "❌ Non configuré",
|
||||
inline=True
|
||||
)
|
||||
embed.add_field(
|
||||
name="🎭 Rôle d'absence",
|
||||
value=f"<@&{role_id}>" if role_id else "❌ Non configuré",
|
||||
inline=True
|
||||
)
|
||||
|
||||
await interaction.response.send_message(embed=embed, view=AbsenceConfigView(interaction.guild.id, self.bot), ephemeral=True)
|
||||
|
||||
async def strike_absence_message(self, guild: disnake.Guild, channel_id: int, message_id: int, info: dict = None):
|
||||
"""Modifie le message d'absence pour le barrer au lieu de le supprimer"""
|
||||
channel = guild.get_channel(channel_id)
|
||||
if not channel:
|
||||
try: channel = await guild.fetch_channel(channel_id)
|
||||
except: return
|
||||
|
||||
try:
|
||||
msg = await channel.fetch_message(message_id)
|
||||
|
||||
# Gestion des Rich Components V2
|
||||
if info:
|
||||
avatar_url = info.get("avatar_url")
|
||||
if avatar_url:
|
||||
header_component = disnake.ui.Section(
|
||||
f"## ~~ABSENCE STAFF [TERMINÉ]~~\n~~**👤 Membre** : {info.get('pseudo', 'Inconnu')}~~",
|
||||
accessory=disnake.ui.Thumbnail(avatar_url)
|
||||
)
|
||||
else:
|
||||
header_component = disnake.ui.TextDisplay(f"## ~~ABSENCE STAFF [TERMINÉ]~~\n~~**👤 Membre** : {info.get('pseudo', 'Inconnu')}~~")
|
||||
|
||||
components = [
|
||||
disnake.ui.Container(
|
||||
header_component,
|
||||
disnake.ui.Separator(),
|
||||
disnake.ui.TextDisplay(f"~~**⏳ Durée** : {info.get('duree', 'N/A')}~~"),
|
||||
disnake.ui.TextDisplay(f"~~**📅 Période** : {info.get('debut')} ➔ {info.get('fin')}~~"),
|
||||
disnake.ui.TextDisplay(f"~~**📝 Motif** : {info.get('motif')}~~"),
|
||||
disnake.ui.TextDisplay(f"~~**🛡️ Supérieur** : {info.get('superieur', 'Aucun')}~~")
|
||||
)
|
||||
]
|
||||
print(f"[DEBUG] Tentative d'édition du message V2 {message_id}")
|
||||
try:
|
||||
await msg.edit(components=components)
|
||||
print(f"[DEBUG] Succès édition V2")
|
||||
except Exception as e:
|
||||
print(f"[DEBUG] Erreur édition V2: {e}")
|
||||
return
|
||||
|
||||
# Ancien format (Texte simple) - fallback
|
||||
content = msg.content
|
||||
if not content or "~~" in content: return
|
||||
|
||||
lines = content.split("\n")
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
if line.startswith("# "):
|
||||
new_lines.append(f"# ~~{line[2:].strip()}~~ [TERMINÉ]")
|
||||
elif line.strip():
|
||||
new_lines.append(f"~~{line}~~")
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
await msg.edit(content="\n".join(new_lines))
|
||||
except Exception as e:
|
||||
print(f"Erreur lors du barrage du message d'absence: {e}")
|
||||
|
||||
@commands.slash_command(name="fin_absence", description="Terminer son absence staff")
|
||||
@check_premium_tier()
|
||||
async def fin_absence(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
"""Met fin à l'absence en cours"""
|
||||
all_abs = await load_absences()
|
||||
guild_abs = get_guild_absences(all_abs, interaction.guild.id)
|
||||
user_id = str(interaction.user.id)
|
||||
|
||||
if user_id not in guild_abs:
|
||||
return await interaction.response.send_message("❌ Tu n'as pas d'absence enregistrée sur ce serveur.", ephemeral=True)
|
||||
|
||||
info = guild_abs[user_id]
|
||||
message_id = info.get("message_id")
|
||||
|
||||
settings = load_settings(interaction.guild.id)
|
||||
channel_id = settings.get("absence_channel_id")
|
||||
|
||||
if channel_id and message_id:
|
||||
await self.strike_absence_message(interaction.guild, int(channel_id), int(message_id), info)
|
||||
|
||||
# Retrait du rôle
|
||||
role_id = settings.get("absence_role_id")
|
||||
if role_id:
|
||||
role = interaction.guild.get_role(int(role_id))
|
||||
if role:
|
||||
try: await interaction.user.remove_roles(role, reason="Fin d'absence staff")
|
||||
except: pass
|
||||
|
||||
del guild_abs[user_id]
|
||||
all_abs = update_guild_absences(all_abs, interaction.guild.id, guild_abs)
|
||||
await save_absences(all_abs)
|
||||
await interaction.response.send_message("✅ Ton absence a été retirée.", ephemeral=True)
|
||||
|
||||
@commands.slash_command(name="liste_absences", description="Afficher la liste des absences staff en cours")
|
||||
@check_premium_tier()
|
||||
async def liste_absences(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
"""Affiche les absences en cours sur le serveur"""
|
||||
all_abs = await load_absences()
|
||||
guild_abs = get_guild_absences(all_abs, interaction.guild.id)
|
||||
|
||||
absences = [
|
||||
(user_id, info) for user_id, info in guild_abs.items()
|
||||
if info.get("etat") == "en cours"
|
||||
]
|
||||
|
||||
if not absences:
|
||||
return await interaction.response.send_message("Aucune absence staff en cours sur ce serveur.", ephemeral=True)
|
||||
|
||||
per_page = 5
|
||||
pages = [absences[i:i + per_page] for i in range(0, len(absences), per_page)]
|
||||
|
||||
def create_embed(page_index: int) -> disnake.Embed:
|
||||
embed = disnake.Embed(
|
||||
title=f"📋 Absences staff - {interaction.guild.name} ({page_index + 1}/{len(pages)})",
|
||||
color=disnake.Color.orange()
|
||||
)
|
||||
for user_id, info in pages[page_index]:
|
||||
embed.add_field(
|
||||
name=info["pseudo"],
|
||||
value=(
|
||||
f"**Période** : du {info['debut']} au {info['fin']}\n"
|
||||
f"**Motif** : {info['motif']}\n"
|
||||
f"**Supérieur** : {info['superieur']}"
|
||||
),
|
||||
inline=False
|
||||
)
|
||||
embed.set_footer(text="Gestion des absences du staff")
|
||||
return embed
|
||||
|
||||
class PaginatorView(disnake.ui.View):
|
||||
def __init__(self):
|
||||
super().__init__(timeout=60)
|
||||
self.page = 0
|
||||
|
||||
@disnake.ui.button(label="⬅️ Précédent", style=disnake.ButtonStyle.secondary, disabled=True)
|
||||
async def previous(self, interaction_btn: disnake.Interaction, button: disnake.ui.Button):
|
||||
self.page -= 1
|
||||
self.update_buttons()
|
||||
await interaction_btn.response.edit_message(embed=create_embed(self.page), view=self)
|
||||
|
||||
@disnake.ui.button(label="➡️ Suivant", style=disnake.ButtonStyle.secondary, disabled=len(pages) == 1)
|
||||
async def next(self, interaction_btn: disnake.Interaction, button: disnake.ui.Button):
|
||||
self.page += 1
|
||||
self.update_buttons()
|
||||
await interaction_btn.response.edit_message(embed=create_embed(self.page), view=self)
|
||||
|
||||
def update_buttons(self):
|
||||
self.children[0].disabled = self.page == 0
|
||||
self.children[1].disabled = self.page >= len(pages) - 1
|
||||
|
||||
await interaction.response.send_message(embed=create_embed(0), view=PaginatorView(), ephemeral=True)
|
||||
|
||||
@tasks.loop(minutes=1)
|
||||
async def check_absences(self):
|
||||
all_abs = await load_absences()
|
||||
now = get_paris_now()
|
||||
changed = False
|
||||
|
||||
for guild_id_str, guild_data in list(all_abs.items()):
|
||||
guild_id = int(guild_id_str)
|
||||
guild = self.bot.get_guild(guild_id)
|
||||
if not guild:
|
||||
try:
|
||||
guild = await self.bot.fetch_guild(guild_id)
|
||||
except:
|
||||
continue
|
||||
|
||||
settings = load_settings(guild_id)
|
||||
role_id = settings.get("absence_role_id")
|
||||
role = guild.get_role(int(role_id)) if role_id else None
|
||||
|
||||
to_remove = []
|
||||
for user_id_str, info in guild_data.items():
|
||||
try:
|
||||
debut = parse_date(info["debut"])
|
||||
fin = parse_date(info["fin"])
|
||||
except Exception as e:
|
||||
print(f"Erreur parsing date pour {user_id_str}: {e}")
|
||||
continue
|
||||
|
||||
user_id = int(user_id_str)
|
||||
member = guild.get_member(user_id)
|
||||
if not member:
|
||||
try:
|
||||
member = await guild.fetch_member(user_id)
|
||||
except:
|
||||
pass
|
||||
|
||||
etat = info.get("etat", "en cours")
|
||||
|
||||
# Activation si l'heure est arrivée
|
||||
if etat == "à venir" and debut <= now:
|
||||
print(f"Activation absence pour {info['pseudo']} ({user_id_str})")
|
||||
info["etat"] = "en cours"
|
||||
changed = True
|
||||
if member and role:
|
||||
try:
|
||||
await member.add_roles(role, reason="Début d'absence staff")
|
||||
except Exception as e:
|
||||
print(f"Erreur ajout rôle absence: {e}")
|
||||
|
||||
# Désactivation si l'heure est passée
|
||||
if fin < now:
|
||||
print(f"Désactivation absence pour {info['pseudo']} ({user_id_str})")
|
||||
if member and role:
|
||||
try:
|
||||
await member.remove_roles(role, reason="Fin d'absence staff")
|
||||
except Exception as e:
|
||||
print(f"Erreur retrait rôle absence: {e}")
|
||||
|
||||
# On barre aussi le message en automatique
|
||||
message_id = info.get("message_id")
|
||||
channel_id = settings.get("absence_channel_id")
|
||||
if channel_id and message_id:
|
||||
await self.strike_absence_message(guild, int(channel_id), int(message_id), info)
|
||||
|
||||
to_remove.append(user_id_str)
|
||||
changed = True
|
||||
|
||||
for uid in to_remove:
|
||||
guild_data.pop(uid, None)
|
||||
|
||||
if changed:
|
||||
await save_absences(all_abs)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message):
|
||||
if message.author.bot or not message.guild:
|
||||
return
|
||||
|
||||
all_abs = await load_absences()
|
||||
guild_abs = get_guild_absences(all_abs, message.guild.id)
|
||||
|
||||
# 1. Rappel à l'auteur s'il est absent
|
||||
if str(message.author.id) in guild_abs:
|
||||
if guild_abs[str(message.author.id)].get("etat") == "en cours":
|
||||
await message.channel.send(
|
||||
f"{message.author.mention}, tu es actuellement déclaré absent sur ce serveur. Merci de prévenir si tu es de retour !",
|
||||
delete_after=10
|
||||
)
|
||||
|
||||
# 2. Notification des mentions de staff absents
|
||||
for user_id_str, info in guild_abs.items():
|
||||
if info.get("etat") != "en cours":
|
||||
continue
|
||||
|
||||
if f"<@{user_id_str}>" in message.content or f"<@!{user_id_str}>" in message.content:
|
||||
msg_id = info.get("message_id")
|
||||
settings = load_settings(message.guild.id)
|
||||
channel_id = settings.get("absence_channel_id")
|
||||
|
||||
absence_url = f"https://discord.com/channels/{message.guild.id}/{channel_id}/{msg_id}" if channel_id and msg_id else ""
|
||||
|
||||
reply_text = f"⚠️ {message.author.mention}, cet utilisateur est actuellement absent."
|
||||
if absence_url:
|
||||
reply_text += f"\nPlus d'infos ici : [Voir la déclaration]({absence_url})"
|
||||
|
||||
await message.reply(reply_text, delete_after=30)
|
||||
|
||||
def setup(bot):
|
||||
bot.add_cog(Absence(bot))
|
||||
|
|
@ -7,9 +7,9 @@ import asyncio
|
|||
from datetime import datetime
|
||||
from typing import Dict, Any, List
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
import disnake
|
||||
from disnake.ext import commands
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
BACKUPS_ROOT = "data/security_backups" # dossier de sortie des sauvegardes
|
||||
|
||||
|
|
@ -46,10 +46,10 @@ def estimate_duration_seconds(
|
|||
return int(meta_cost + msg_cost + base_overhead)
|
||||
|
||||
|
||||
def serialize_overwrites(channel: discord.abc.GuildChannel) -> List[Dict[str, Any]]:
|
||||
def serialize_overwrites(channel: disnake.abc.GuildChannel) -> List[Dict[str, Any]]:
|
||||
serialized: List[Dict[str, Any]] = []
|
||||
for target, perms in channel.overwrites.items():
|
||||
target_type = "role" if isinstance(target, discord.Role) else "member"
|
||||
target_type = "role" if isinstance(target, disnake.Role) else "member"
|
||||
serialized.append({
|
||||
"target_type": target_type,
|
||||
"target_id": target.id,
|
||||
|
|
@ -64,20 +64,18 @@ class Backup(commands.Cog):
|
|||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
backup_group = app_commands.Group(name="backup", description="Outils de sauvegarde du serveur")
|
||||
@commands.slash_command(name="backup", description="Outils de sauvegarde du serveur")
|
||||
@check_premium_tier()
|
||||
async def backup_group(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
pass
|
||||
|
||||
@backup_group.command(name="create", description="Créer une sauvegarde du serveur")
|
||||
@app_commands.describe(
|
||||
save_messages="Inclure l'export des messages",
|
||||
privacy_notice="Afficher un avertissement public si messages sauvegardés",
|
||||
message_limit_per_channel="Nombre max de messages à exporter par salon (0 = tous)"
|
||||
)
|
||||
@backup_group.sub_command(name="create", description="Créer une sauvegarde du serveur")
|
||||
async def backup_create(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
save_messages: bool = True,
|
||||
privacy_notice: bool = True,
|
||||
message_limit_per_channel: int = 10000
|
||||
interaction: disnake.ApplicationCommandInteraction,
|
||||
save_messages: bool = commands.Param(True, description="Inclure l'export des messages"),
|
||||
privacy_notice: bool = commands.Param(True, description="Afficher un avertissement public si messages sauvegardés"),
|
||||
message_limit_per_channel: int = commands.Param(10000, description="Nombre max de messages à exporter par salon (0 = tous)")
|
||||
):
|
||||
guild = interaction.guild
|
||||
assert guild is not None, "Cette commande doit être utilisée dans un serveur."
|
||||
|
|
@ -93,20 +91,20 @@ class Backup(commands.Cog):
|
|||
)
|
||||
return
|
||||
|
||||
# Déferre l'interaction
|
||||
await interaction.response.defer(ephemeral=True, thinking=True)
|
||||
# Deferre l'interaction
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
# Avertissement de confidentialité
|
||||
if privacy_notice and save_messages:
|
||||
try:
|
||||
await interaction.channel.send(
|
||||
embed=discord.Embed(
|
||||
embed=disnake.Embed(
|
||||
title="📢 Information de confidentialité (transparence)",
|
||||
description=(
|
||||
"Une **sauvegarde du serveur** est en cours et **inclus les messages**.\n"
|
||||
"Vous êtes dans l'obligation de prévenir les membres."
|
||||
),
|
||||
color=discord.Color.orange()
|
||||
color=disnake.Color.orange()
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
|
|
@ -132,14 +130,14 @@ class Backup(commands.Cog):
|
|||
message_limit_per_channel if message_limit_per_channel else 100000
|
||||
)
|
||||
|
||||
prog_embed = discord.Embed(
|
||||
prog_embed = disnake.Embed(
|
||||
title="🗄️ Sauvegarde en cours",
|
||||
description=(
|
||||
f"**Serveur :** {guild.name} (`{guild.id}`)\n"
|
||||
f"**Estimation :** ~{estimation_s} secondes\n"
|
||||
f"**Options** : messages={'oui' if save_messages else 'non'} • limite/chan={message_limit_per_channel or 'tous'}"
|
||||
),
|
||||
color=discord.Color.blurple()
|
||||
color=disnake.Color.blurple()
|
||||
)
|
||||
prog_embed.add_field(name="Progression", value=progress_bar(0.0), inline=False)
|
||||
progress_msg = await interaction.followup.send(embed=prog_embed, ephemeral=False)
|
||||
|
|
@ -173,11 +171,11 @@ class Backup(commands.Cog):
|
|||
chans_dump=[]
|
||||
for ch in guild.channels:
|
||||
base={"id":ch.id,"name":ch.name,"type":str(ch.type),"position":ch.position,"category_id":ch.category.id if getattr(ch,"category",None) else None,"overwrites":serialize_overwrites(ch)}
|
||||
if isinstance(ch,discord.TextChannel):
|
||||
if isinstance(ch,disnake.TextChannel):
|
||||
base.update({"topic":ch.topic,"nsfw":ch.nsfw,"slowmode_delay":ch.slowmode_delay,"default_auto_archive_duration":ch.default_auto_archive_duration})
|
||||
elif isinstance(ch,discord.VoiceChannel):
|
||||
elif isinstance(ch,disnake.VoiceChannel):
|
||||
base.update({"bitrate":ch.bitrate,"user_limit":ch.user_limit,"rtc_region":str(ch.rtc_region) if ch.rtc_region else None})
|
||||
elif isinstance(ch,discord.ForumChannel):
|
||||
elif isinstance(ch,disnake.ForumChannel):
|
||||
base.update({"nsfw":ch.nsfw,"default_auto_archive_duration":ch.default_auto_archive_duration})
|
||||
chans_dump.append(base)
|
||||
with open(os.path.join(base_dir,"channels.json"),"w",encoding="utf-8") as f:
|
||||
|
|
@ -208,7 +206,7 @@ class Backup(commands.Cog):
|
|||
data={"id":msg.id,"channel_id":msg.channel.id,"author_id":msg.author.id,"author_bot":msg.author.bot,"created_at":msg.created_at.isoformat(),"content":msg.content,"attachments":[a.url for a in msg.attachments],"embeds":[e.to_dict() for e in msg.embeds],"reference":{"message_id":getattr(msg.reference,"message_id",None),"channel_id":getattr(msg.reference,"channel_id",None)} if msg.reference else None}
|
||||
f.write(json.dumps(data,ensure_ascii=False)+"\n")
|
||||
count+=1
|
||||
except discord.Forbidden: pass
|
||||
except disnake.Forbidden: pass
|
||||
except Exception: pass
|
||||
exported_total+=count
|
||||
done+=per_chan_weight
|
||||
|
|
@ -226,7 +224,7 @@ class Backup(commands.Cog):
|
|||
rel=os.path.relpath(full,base_dir)
|
||||
zf.write(full,rel)
|
||||
except Exception as exc:
|
||||
prog_embed.color=discord.Color.red()
|
||||
prog_embed.color=disnake.Color.red()
|
||||
prog_embed.set_field_at(0,name="Progression",value=progress_bar(1.0),inline=False)
|
||||
prog_embed.set_footer(text=f"Erreur lors de la création du ZIP : {exc}")
|
||||
await progress_msg.edit(embed=prog_embed)
|
||||
|
|
@ -234,15 +232,15 @@ class Backup(commands.Cog):
|
|||
return
|
||||
|
||||
# Finalisation
|
||||
prog_embed.color=discord.Color.green()
|
||||
prog_embed.color=disnake.Color.green()
|
||||
prog_embed.set_field_at(0,name="Progression",value=progress_bar(1.0),inline=False)
|
||||
prog_embed.add_field(name="Archive",value=f"`{zip_path}`",inline=False)
|
||||
prog_embed.set_footer(text="Sauvegarde terminée ✅")
|
||||
await progress_msg.edit(embed=prog_embed)
|
||||
await interaction.followup.send("🎉 Sauvegarde terminée. Un message de progression avec le chemin de l’archive a été publié.", ephemeral=True)
|
||||
|
||||
@backup_group.command(name="list", description="Lister les archives de sauvegarde disponibles")
|
||||
async def backup_list(self, interaction: discord.Interaction):
|
||||
@backup_group.sub_command(name="list", description="Lister les archives de sauvegarde disponibles")
|
||||
async def backup_list(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
guild = interaction.guild
|
||||
assert guild is not None, "Cette commande doit être utilisée dans un serveur."
|
||||
|
||||
|
|
@ -256,7 +254,7 @@ class Backup(commands.Cog):
|
|||
)
|
||||
return
|
||||
|
||||
await interaction.response.defer(ephemeral=True, thinking=True)
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
ensure_dir(BACKUPS_ROOT)
|
||||
files = [f for f in os.listdir(BACKUPS_ROOT) if f.endswith(".zip")]
|
||||
files.sort(reverse=True)
|
||||
|
|
@ -266,11 +264,11 @@ class Backup(commands.Cog):
|
|||
return
|
||||
|
||||
desc="\n".join(f"• `{name}`" for name in files[:20])
|
||||
embed=discord.Embed(title="📦 Archives disponibles",description=desc,color=discord.Color.blurple())
|
||||
embed=disnake.Embed(title="📦 Archives disponibles",description=desc,color=disnake.Color.blurple())
|
||||
if len(files)>20:
|
||||
embed.set_footer(text=f"... et {len(files)-20} de plus")
|
||||
await interaction.followup.send(embed=embed,ephemeral=True)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(Backup(bot))
|
||||
def setup(bot):
|
||||
bot.add_cog(Backup(bot))
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import zipfile
|
|||
import tempfile
|
||||
import shutil
|
||||
from typing import List
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
import disnake
|
||||
from disnake.ext import commands
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
BACKUPS_ROOT = "data/security_backups"
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ class BackupRestore(commands.Cog):
|
|||
elements.append(base)
|
||||
return elements
|
||||
|
||||
async def element_autocomplete(self, interaction: discord.Interaction, current: str):
|
||||
async def element_autocomplete(self, interaction: disnake.ApplicationCommandInteraction, current: str):
|
||||
"""Complétion automatique pour l'option 'element'."""
|
||||
guild = interaction.guild
|
||||
if guild is None:
|
||||
|
|
@ -60,9 +60,9 @@ class BackupRestore(commands.Cog):
|
|||
backup_path = os.path.join(BACKUPS_ROOT, last_backup)
|
||||
elements = await self.list_backup_elements(backup_path)
|
||||
|
||||
return [app_commands.Choice(name=e, value=e) for e in elements if current.lower() in e.lower()][:25]
|
||||
return [e for e in elements if current.lower() in e.lower()][:25]
|
||||
|
||||
async def restore_roles(self, guild: discord.Guild, backup_path: str):
|
||||
async def restore_roles(self, guild: disnake.Guild, backup_path: str):
|
||||
"""Restaure les rôles à partir du backup."""
|
||||
roles_file = os.path.join(backup_path, "roles.json")
|
||||
if os.path.isfile(roles_file):
|
||||
|
|
@ -72,16 +72,16 @@ class BackupRestore(commands.Cog):
|
|||
try:
|
||||
await guild.create_role(
|
||||
name=r["name"],
|
||||
permissions=discord.Permissions(r["permissions"]),
|
||||
color=discord.Color(r.get("color", 0)),
|
||||
permissions=disnake.Permissions(r["permissions"]),
|
||||
color=disnake.Color(r.get("color", 0)),
|
||||
hoist=r.get("hoist", False),
|
||||
mentionable=r.get("mentionable", False),
|
||||
reason="Restauration backup"
|
||||
)
|
||||
except discord.Forbidden:
|
||||
except disnake.Forbidden:
|
||||
continue
|
||||
|
||||
async def restore_channels(self, guild: discord.Guild, backup_path: str):
|
||||
async def restore_channels(self, guild: disnake.Guild, backup_path: str):
|
||||
"""Restaure les channels à partir du backup."""
|
||||
channels_file = os.path.join(backup_path, "channels.json")
|
||||
if os.path.isfile(channels_file):
|
||||
|
|
@ -105,10 +105,10 @@ class BackupRestore(commands.Cog):
|
|||
user_limit=c.get("user_limit", 0),
|
||||
reason="Restauration backup"
|
||||
)
|
||||
except discord.Forbidden:
|
||||
except disnake.Forbidden:
|
||||
continue
|
||||
|
||||
async def restore_messages(self, channel: discord.TextChannel, backup_path: str):
|
||||
async def restore_messages(self, channel: disnake.TextChannel, backup_path: str):
|
||||
"""Restaure les messages à partir du backup (solution ZIP incluse)."""
|
||||
if backup_path.endswith(".zip"):
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
|
|
@ -133,17 +133,13 @@ class BackupRestore(commands.Cog):
|
|||
for msg in messages:
|
||||
await channel.send(msg["content"])
|
||||
|
||||
@app_commands.command(name="granulaire", description="Restaure un élément précis d'une sauvegarde")
|
||||
@app_commands.describe(
|
||||
backup="Nom ou ID de la sauvegarde",
|
||||
element="Élément à restaurer (roles, channels, messages...)"
|
||||
)
|
||||
@app_commands.autocomplete(element=element_autocomplete)
|
||||
@commands.slash_command(name="granulaire", description="Restaure un élément précis d'une sauvegarde")
|
||||
@check_premium_tier()
|
||||
async def restore_granulaire(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
backup: str,
|
||||
element: str
|
||||
interaction: disnake.ApplicationCommandInteraction,
|
||||
backup: str = commands.Param(description="Nom ou ID de la sauvegarde"),
|
||||
element: str = commands.Param(description="Élément à restaurer (roles, channels, messages...)")
|
||||
):
|
||||
guild = interaction.guild
|
||||
if guild is None:
|
||||
|
|
@ -162,7 +158,7 @@ class BackupRestore(commands.Cog):
|
|||
await interaction.response.send_message(f"❌ La sauvegarde `{backup}` est introuvable.", ephemeral=True)
|
||||
return
|
||||
|
||||
await interaction.response.defer(ephemeral=True, thinking=True)
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
available_elements = await self.list_backup_elements(backup_path)
|
||||
if element not in available_elements:
|
||||
|
|
@ -200,5 +196,9 @@ class BackupRestore(commands.Cog):
|
|||
ephemeral=True
|
||||
)
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(BackupRestore(bot))
|
||||
@restore_granulaire.autocomplete("element")
|
||||
async def element_autocomplete_decorator(self, interaction: disnake.ApplicationCommandInteraction, current: str):
|
||||
return await self.element_autocomplete(interaction, current)
|
||||
|
||||
def setup(bot):
|
||||
bot.add_cog(BackupRestore(bot))
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import discord
|
||||
from discord.ext import commands, tasks
|
||||
from discord import app_commands
|
||||
import disnake
|
||||
from disnake.ext import commands, tasks
|
||||
import json
|
||||
import os
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
BLACKLIST_FILE = "data/blacklist.json"
|
||||
AGENTS_FILE = "data/agents.json"
|
||||
|
|
@ -57,8 +57,9 @@ class Blacklist(commands.Cog):
|
|||
agents = load_json(AGENTS_FILE, {"agents": []})
|
||||
return str(user_id) in agents["agents"]
|
||||
|
||||
@app_commands.command(name="blacklist", description="Blacklist un utilisateur")
|
||||
async def blacklist(self, interaction: discord.Interaction, user: discord.User, reason: str = "Aucune raison fournie"):
|
||||
@commands.slash_command(name="blacklist", description="Blacklist un utilisateur")
|
||||
@check_premium_tier()
|
||||
async def blacklist(self, interaction: disnake.ApplicationCommandInteraction, user: disnake.User, reason: str = "Aucune raison fournie"):
|
||||
if not self.is_agent(interaction.user.id):
|
||||
return await interaction.response.send_message("⛔ Tu n’as pas la permission.", ephemeral=True)
|
||||
|
||||
|
|
@ -70,7 +71,7 @@ class Blacklist(commands.Cog):
|
|||
save_json(BLACKLIST_FILE, blacklist)
|
||||
|
||||
# Préparer l'embed pro
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title="🚫 Mise en blacklist - Réseau Omega Kube",
|
||||
description=(
|
||||
f"Bonjour {user.mention},\n\n"
|
||||
|
|
@ -82,7 +83,7 @@ class Blacklist(commands.Cog):
|
|||
"📩 Vous disposez de **7 jours** pour contester cette décision en ouvrant un ticket via le bouton ci-dessous.\n\n"
|
||||
"⏳ Passé ce délai, vous serez **définitivement banni de tout le réseau**, sans autre recours possible."
|
||||
),
|
||||
color=discord.Color.red()
|
||||
color=disnake.Color.red()
|
||||
)
|
||||
embed.set_footer(text="Omega Kube - Nixfix06 & Mgstudios")
|
||||
|
||||
|
|
@ -122,8 +123,9 @@ class Blacklist(commands.Cog):
|
|||
ephemeral=True
|
||||
)
|
||||
|
||||
@app_commands.command(name="unblacklist", description="Retirer un utilisateur de la blacklist")
|
||||
async def unblacklist(self, interaction: discord.Interaction, user: discord.User):
|
||||
@commands.slash_command(name="unblacklist", description="Retirer un utilisateur de la blacklist")
|
||||
@check_premium_tier()
|
||||
async def unblacklist(self, interaction: disnake.ApplicationCommandInteraction, user: disnake.User):
|
||||
if not self.is_agent(interaction.user.id):
|
||||
return await interaction.response.send_message("⛔ Tu n’as pas la permission.", ephemeral=True)
|
||||
|
||||
|
|
@ -163,21 +165,21 @@ class Blacklist(commands.Cog):
|
|||
save_json(BLACKLIST_FILE, blacklist)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message: discord.Message):
|
||||
async def on_message(self, message: disnake.Message):
|
||||
if message.author.bot:
|
||||
return
|
||||
|
||||
tickets = load_json(TICKETS_FILE, {})
|
||||
|
||||
if isinstance(message.channel, discord.DMChannel):
|
||||
if isinstance(message.channel, disnake.DMChannel):
|
||||
user_id = str(message.author.id)
|
||||
if user_id in tickets:
|
||||
guild = self.bot.get_guild(MAIN_GUILD_ID)
|
||||
ticket_channel = guild.get_channel(tickets[user_id]["channel_id"])
|
||||
if ticket_channel:
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
description=message.content,
|
||||
color=discord.Color.blue()
|
||||
color=disnake.Color.blue()
|
||||
).set_author(name=message.author, icon_url=message.author.display_avatar.url)
|
||||
await ticket_channel.send(embed=embed)
|
||||
|
||||
|
|
@ -186,22 +188,22 @@ class Blacklist(commands.Cog):
|
|||
if message.channel.id == data["channel_id"] and self.is_agent(message.author.id):
|
||||
user = await self.bot.fetch_user(int(user_id))
|
||||
try:
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
description=message.content,
|
||||
color=discord.Color.green()
|
||||
color=disnake.Color.green()
|
||||
).set_author(name=f"Agent {message.author}", icon_url=message.author.display_avatar.url)
|
||||
await user.send(embed=embed)
|
||||
except:
|
||||
pass
|
||||
|
||||
# --------- Boutons ---------
|
||||
class TicketButton(discord.ui.View):
|
||||
class TicketButton(disnake.ui.View):
|
||||
def __init__(self, user_id: int):
|
||||
super().__init__(timeout=None)
|
||||
self.user_id = user_id
|
||||
|
||||
@discord.ui.button(label="📩 Ouvrir une demande de révision", style=discord.ButtonStyle.primary, custom_id="blacklist_open_ticket")
|
||||
async def open_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
@disnake.ui.button(label="📩 Ouvrir une demande de révision", style=disnake.ButtonStyle.primary, custom_id="blacklist_open_ticket")
|
||||
async def open_ticket(self, button: disnake.ui.Button, interaction: disnake.Interaction):
|
||||
if interaction.user.id != self.user_id:
|
||||
return await interaction.response.send_message("❌ Ce bouton ne t’appartient pas.", ephemeral=True)
|
||||
|
||||
|
|
@ -209,7 +211,7 @@ class TicketButton(discord.ui.View):
|
|||
if str(self.user_id) in tickets:
|
||||
return await interaction.response.send_message("❌ Tu as déjà un ticket ouvert.", ephemeral=True)
|
||||
|
||||
guild = interaction.client.get_guild(MAIN_GUILD_ID)
|
||||
guild = interaction.bot.get_guild(MAIN_GUILD_ID)
|
||||
category = guild.get_channel(TICKET_CATEGORY_ID)
|
||||
|
||||
ticket_channel = await guild.create_text_channel(
|
||||
|
|
@ -222,10 +224,10 @@ class TicketButton(discord.ui.View):
|
|||
save_json(TICKETS_FILE, tickets)
|
||||
|
||||
view_close = CloseTicketView(self.user_id)
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title="📩 Ticket ouvert",
|
||||
description=f"Un utilisateur blacklisté (<@{self.user_id}>) a ouvert une demande de révision.\n\nUn agent Omega Kube prendra en charge ce dossier.",
|
||||
color=discord.Color.blurple()
|
||||
color=disnake.Color.blurple()
|
||||
)
|
||||
await ticket_channel.send(embed=embed, view=view_close)
|
||||
|
||||
|
|
@ -234,27 +236,27 @@ class TicketButton(discord.ui.View):
|
|||
button.disabled = True
|
||||
self.stop()
|
||||
|
||||
class CloseTicketView(discord.ui.View):
|
||||
class CloseTicketView(disnake.ui.View):
|
||||
def __init__(self, user_id: int):
|
||||
super().__init__(timeout=None)
|
||||
self.user_id = user_id
|
||||
|
||||
@discord.ui.button(label="❌ Fermer le ticket", style=discord.ButtonStyle.danger, custom_id="blacklist_close_ticket")
|
||||
async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
if not interaction.client.get_cog("Blacklist").is_agent(interaction.user.id):
|
||||
@disnake.ui.button(label="❌ Fermer le ticket", style=disnake.ButtonStyle.danger, custom_id="blacklist_close_ticket")
|
||||
async def close_ticket(self, button: disnake.ui.Button, interaction: disnake.Interaction):
|
||||
if not interaction.bot.get_cog("Blacklist").is_agent(interaction.user.id):
|
||||
return await interaction.response.send_message("⛔ Seuls les agents peuvent fermer ce ticket.", ephemeral=True)
|
||||
|
||||
view = ConfirmCloseView(self.user_id)
|
||||
await interaction.response.send_message("⚠️ Veux-tu vraiment fermer ce ticket ?", view=view, ephemeral=True)
|
||||
|
||||
class ConfirmCloseView(discord.ui.View):
|
||||
class ConfirmCloseView(disnake.ui.View):
|
||||
def __init__(self, user_id: int):
|
||||
super().__init__(timeout=30)
|
||||
self.user_id = user_id
|
||||
|
||||
@discord.ui.button(label="✅ Oui", style=discord.ButtonStyle.success, custom_id="blacklist_confirm_close")
|
||||
async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
if not interaction.client.get_cog("Blacklist").is_agent(interaction.user.id):
|
||||
@disnake.ui.button(label="✅ Oui", style=disnake.ButtonStyle.success, custom_id="blacklist_confirm_close")
|
||||
async def confirm(self, button: disnake.ui.Button, interaction: disnake.Interaction):
|
||||
if not interaction.bot.get_cog("Blacklist").is_agent(interaction.user.id):
|
||||
return await interaction.response.send_message("⛔ Tu n’as pas la permission.", ephemeral=True)
|
||||
|
||||
tickets = load_json(TICKETS_FILE, {})
|
||||
|
|
@ -268,19 +270,19 @@ class ConfirmCloseView(discord.ui.View):
|
|||
del tickets[str(self.user_id)]
|
||||
save_json(TICKETS_FILE, tickets)
|
||||
|
||||
user = await interaction.client.fetch_user(int(self.user_id))
|
||||
user = await interaction.bot.fetch_user(int(self.user_id))
|
||||
try:
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title="🔒 Ticket fermé",
|
||||
description="Ton ticket a été examiné et fermé.\n\n⚠️ Après analyse, la sanction est confirmée et tu restes blacklisté de l’ensemble du réseau.",
|
||||
color=discord.Color.orange()
|
||||
color=disnake.Color.orange()
|
||||
)
|
||||
await user.send(embed=embed)
|
||||
await asyncio.sleep(2)
|
||||
except:
|
||||
pass
|
||||
|
||||
for guild in interaction.client.guilds:
|
||||
for guild in interaction.bot.guilds:
|
||||
member = guild.get_member(self.user_id)
|
||||
if member:
|
||||
try:
|
||||
|
|
@ -290,10 +292,10 @@ class ConfirmCloseView(discord.ui.View):
|
|||
|
||||
await interaction.response.send_message("✅ Ticket fermé avec succès.", ephemeral=True)
|
||||
|
||||
@discord.ui.button(label="❌ Non", style=discord.ButtonStyle.secondary, custom_id="blacklist_cancel_close")
|
||||
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
@disnake.ui.button(label="❌ Non", style=disnake.ButtonStyle.secondary, custom_id="blacklist_cancel_close")
|
||||
async def cancel(self, button: disnake.ui.Button, interaction: disnake.Interaction):
|
||||
await interaction.response.send_message("❌ Fermeture annulée.", ephemeral=True)
|
||||
|
||||
# --------- Setup ---------
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(Blacklist(bot))
|
||||
def setup(bot):
|
||||
bot.add_cog(Blacklist(bot))
|
||||
|
|
|
|||
|
|
@ -1,137 +1,384 @@
|
|||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands, tasks
|
||||
from utils.gitlab_client import gitlab_client
|
||||
import disnake
|
||||
from disnake.ext import commands
|
||||
from utils.forgejo_client import gitlab_client
|
||||
from aiohttp import web
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import asyncio
|
||||
from typing import Dict, Any, Optional
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
kuby_logger = logging.getLogger("KubyBot")
|
||||
REPORTS_FILE = "data/gitlab_reports.json"
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
# ⚡ NOUVEAU: Utilise le même fichier que gitlab_integration.py
|
||||
TRACKING_FILE = os.path.join(BASE_DIR, "commandes", "gitlab_issues.json")
|
||||
LEGACY_REPORTS_FILE = os.path.join(BASE_DIR, "data", "gitlab_reports.json")
|
||||
|
||||
def save_report(issue_iid, user_id):
|
||||
os.makedirs("data", exist_ok=True)
|
||||
try:
|
||||
if os.path.exists(REPORTS_FILE):
|
||||
with open(REPORTS_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
data = {}
|
||||
|
||||
data[str(issue_iid)] = user_id
|
||||
|
||||
with open(REPORTS_FILE, "w") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Error saving report to JSON: {e}")
|
||||
|
||||
class BugReportModal(discord.ui.Modal, title="Signaler un Bug"):
|
||||
def __init__(self, priority_choice):
|
||||
super().__init__()
|
||||
self.priority_choice = priority_choice
|
||||
|
||||
bug_title = discord.ui.TextInput(
|
||||
label="Titre du bug",
|
||||
placeholder="Décrivez brièvement le problème...",
|
||||
required=True,
|
||||
max_length=100
|
||||
)
|
||||
def load_tracking():
|
||||
"""Charge le tracking depuis gitlab_issues.json (avec migration automatique)"""
|
||||
tracking = {}
|
||||
if os.path.exists(TRACKING_FILE):
|
||||
try:
|
||||
with open(TRACKING_FILE, "r", encoding="utf-8") as f:
|
||||
tracking = json.load(f)
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Erreur chargement gitlab_issues.json: {e}")
|
||||
|
||||
description = discord.ui.TextInput(
|
||||
label="Description détaillée",
|
||||
style=discord.TextStyle.paragraph,
|
||||
placeholder="Que s'est-il passé ? Comment reproduire le bug ?",
|
||||
required=True,
|
||||
max_length=1000
|
||||
)
|
||||
# Migration automatique depuis l'ancien fichier
|
||||
if os.path.exists(LEGACY_REPORTS_FILE):
|
||||
try:
|
||||
with open(LEGACY_REPORTS_FILE, "r", encoding="utf-8") as f:
|
||||
legacy_data = json.load(f)
|
||||
for issue_iid, user_id in legacy_data.items():
|
||||
if issue_iid not in tracking:
|
||||
tracking[issue_iid] = {
|
||||
"requester_id": user_id,
|
||||
"last_note_id": 0,
|
||||
"type": "legacy_report"
|
||||
}
|
||||
kuby_logger.info(f"Migration automatique: {len(legacy_data)} issues fusionnées")
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Erreur migration gitlab_reports.json: {e}")
|
||||
|
||||
return tracking
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
await interaction.response.send_message("Envoi de votre rapport de bug à GitLab...", ephemeral=True)
|
||||
|
||||
description_text = f"**Rapporté par:** {interaction.user} ({interaction.user.id})\n"
|
||||
description_text += f"**Priorité:** {self.priority_choice.name}\n\n"
|
||||
description_text += f"**Description:**\n{self.description.value}"
|
||||
|
||||
# Mapping des priorités vers des labels GitLab stylisés (Scoped Labels)
|
||||
labels = ["bug", "manual-report", f"Priority::{self.priority_choice.value}"]
|
||||
|
||||
result = await gitlab_client.create_issue(
|
||||
title=f"[MANUAL] {self.bug_title.value}",
|
||||
description=description_text,
|
||||
labels=labels
|
||||
|
||||
def save_tracking(data: Dict[str, Any]) -> None:
|
||||
"""Sauvegarde dans gitlab_issues.json"""
|
||||
os.makedirs(os.path.dirname(TRACKING_FILE), exist_ok=True)
|
||||
temp_file = f"{TRACKING_FILE}.tmp"
|
||||
with open(temp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(temp_file, TRACKING_FILE)
|
||||
|
||||
|
||||
class BugReportModal(disnake.ui.Modal):
|
||||
def __init__(self, cog):
|
||||
self.cog = cog # ⚡ NOUVEAU: Reçoit le cog pour tracking
|
||||
components = [
|
||||
disnake.ui.TextInput(
|
||||
label="Titre du bug",
|
||||
placeholder="Décrivez brièvement le problème...",
|
||||
custom_id="bug_title",
|
||||
required=True,
|
||||
max_length=100,
|
||||
),
|
||||
disnake.ui.TextInput(
|
||||
label="Description détaillée",
|
||||
style=disnake.TextInputStyle.paragraph,
|
||||
placeholder="Que s'est-il passé ?",
|
||||
custom_id="bug_description",
|
||||
required=True,
|
||||
max_length=1000,
|
||||
),
|
||||
]
|
||||
super().__init__(
|
||||
title="Signaler un Bug",
|
||||
custom_id="bug_modal",
|
||||
components=components,
|
||||
)
|
||||
|
||||
|
||||
async def callback(self, interaction: disnake.ModalInteraction):
|
||||
await interaction.response.send_message("Envoi de votre rapport de bug à GitLab...", ephemeral=True)
|
||||
|
||||
bug_title = (interaction.text_values.get("bug_title") or "").strip()
|
||||
if not bug_title:
|
||||
bug_title = "Sans titre"
|
||||
|
||||
priority_name = "Normale"
|
||||
priority_value = "Normal"
|
||||
description_value = interaction.text_values.get("bug_description") or ""
|
||||
|
||||
description_text = f"**Rapporté par:** {interaction.user} ({interaction.user.id})\n"
|
||||
description_text += f"**Priorité:** {priority_name}\n\n"
|
||||
description_text += f"**Description:**\n{description_value}"
|
||||
|
||||
labels = ["bug", "manual-report", f"Priority::{priority_value}"]
|
||||
|
||||
result = await gitlab_client.create_issue(
|
||||
title=f"[MANUAL] {bug_title}",
|
||||
description=description_text,
|
||||
labels=labels,
|
||||
)
|
||||
|
||||
if result:
|
||||
issue_iid = result.get("iid")
|
||||
save_report(issue_iid, interaction.user.id)
|
||||
await interaction.edit_original_response(content=f"✅ Votre bug a été signalé avec succès ! Gameur a bien été averti et le bug sera réglé dans la prochaine mise à jour. [Voir l'issue]({result.get('web_url')})")
|
||||
issue_iid = extract_issue_identifier(result)
|
||||
if not issue_iid:
|
||||
kuby_logger.warning("Impossible de déterminer l’ID de l’issue créée depuis le retour Forgejo/GitLab")
|
||||
await interaction.edit_original_response(content="✅ Votre rapport a été envoyé, mais l’identifiant de l’issue n’a pas pu être récupéré automatiquement.")
|
||||
return
|
||||
|
||||
self.cog.issue_data[issue_iid] = {
|
||||
"requester_id": interaction.user.id,
|
||||
"last_note_id": 0,
|
||||
"type": "manual-report"
|
||||
}
|
||||
save_tracking(self.cog.issue_data)
|
||||
await interaction.edit_original_response(
|
||||
content=(
|
||||
"✅ Votre bug a été signalé avec succès ! "
|
||||
"Nous avons bien été averti et le bug sera réglé dans la prochaine mise à jour. "
|
||||
f"[Voir l'issue]({result.get('web_url') or result.get('html_url')})"
|
||||
)
|
||||
)
|
||||
else:
|
||||
await interaction.edit_original_response(content="❌ Une erreur est survenue lors de l'envoi du rapport à GitLab. Veuillez contacter un administrateur.")
|
||||
await interaction.edit_original_response(
|
||||
content="❌ Une erreur est survenue lors de l'envoi du rapport à GitLab. Veuillez contacter un administrateur."
|
||||
)
|
||||
|
||||
|
||||
class FeatureSuggestionModal(disnake.ui.Modal):
|
||||
def __init__(self, cog):
|
||||
self.cog = cog # ⚡ NOUVEAU: Reçoit le cog pour tracking
|
||||
components = [
|
||||
disnake.ui.TextInput(
|
||||
label="Titre de la suggestion",
|
||||
placeholder="Que voulez-vous ajouter ?",
|
||||
custom_id="suggestion_title",
|
||||
required=True,
|
||||
max_length=100,
|
||||
),
|
||||
disnake.ui.TextInput(
|
||||
label="Détails de la fonctionnalité",
|
||||
style=disnake.TextInputStyle.paragraph,
|
||||
placeholder="Décrivez comment cela devrait fonctionner...",
|
||||
custom_id="suggestion_description",
|
||||
required=True,
|
||||
max_length=1000,
|
||||
),
|
||||
]
|
||||
super().__init__(
|
||||
title="Suggérer une fonctionnalité",
|
||||
custom_id="suggestion_modal",
|
||||
components=components,
|
||||
)
|
||||
|
||||
async def callback(self, interaction: disnake.ModalInteraction):
|
||||
await interaction.response.send_message("Envoi de votre suggestion à GitLab...", ephemeral=True)
|
||||
|
||||
suggestion_title = (interaction.text_values.get("suggestion_title") or "").strip() or "Sans titre"
|
||||
description_value = interaction.text_values.get("suggestion_description") or ""
|
||||
|
||||
description_text = f"**Sugéré par:** {interaction.user} ({interaction.user.id})\n\n"
|
||||
description_text += f"**Description:**\n{description_value}"
|
||||
|
||||
labels = ["enhancement", "manual-suggestion"]
|
||||
|
||||
result = await gitlab_client.create_issue(
|
||||
title=f"[SUGGESTION] {suggestion_title}",
|
||||
description=description_text,
|
||||
labels=labels,
|
||||
)
|
||||
|
||||
if result:
|
||||
issue_iid = extract_issue_identifier(result)
|
||||
if not issue_iid:
|
||||
kuby_logger.warning("Impossible de déterminer l’ID de l’issue créée depuis le retour Forgejo/GitLab")
|
||||
await interaction.edit_original_response(content="✅ Votre suggestion a été envoyée, mais l’identifiant de l’issue n’a pas pu être récupéré automatiquement.")
|
||||
return
|
||||
|
||||
self.cog.issue_data[issue_iid] = {
|
||||
"requester_id": interaction.user.id,
|
||||
"last_note_id": 0,
|
||||
"type": "manual-suggestion"
|
||||
}
|
||||
save_tracking(self.cog.issue_data)
|
||||
await interaction.edit_original_response(
|
||||
content=(
|
||||
"✅ Votre suggestion a été envoyée avec succès ! "
|
||||
"Merci de contribuer à l'amélioration du bot. "
|
||||
f"[Voir l'issue]({result.get('web_url') or result.get('html_url')})"
|
||||
)
|
||||
)
|
||||
else:
|
||||
await interaction.edit_original_response(
|
||||
content="❌ Une erreur est survenue lors de l'envoi de la suggestion à GitLab."
|
||||
)
|
||||
|
||||
|
||||
class BugReportView(disnake.ui.View):
|
||||
def __init__(self, cog):
|
||||
self.cog = cog # ⚡ NOUVEAU: Reçoit le cog
|
||||
super().__init__(timeout=None)
|
||||
|
||||
@disnake.ui.button(label="🎫 Remplir le formulaire", style=disnake.ButtonStyle.primary, custom_id="open_bug_form")
|
||||
async def open_bug_form(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction):
|
||||
modal = BugReportModal(self.cog)
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
|
||||
class FeatureSuggestionView(disnake.ui.View):
|
||||
def __init__(self, cog):
|
||||
self.cog = cog # ⚡ NOUVEAU: Reçoit le cog
|
||||
super().__init__(timeout=None)
|
||||
|
||||
@disnake.ui.button(label="🎫 Remplir le formulaire", style=disnake.ButtonStyle.green, custom_id="open_suggestion_form")
|
||||
async def open_suggestion_form(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction):
|
||||
modal = FeatureSuggestionModal(self.cog)
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
|
||||
class BugReport(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.check_gitlab_issues.start()
|
||||
self.issue_data = load_tracking() # ⚡ NOUVEAU: Charge le tracking unifié
|
||||
|
||||
async def cog_load(self):
|
||||
kuby_logger.info("BugReport cog chargé. Démarrage du serveur de relais interne (port 5001)...")
|
||||
REPORTS_DIR = os.path.dirname(LEGACY_REPORTS_FILE)
|
||||
os.makedirs(REPORTS_DIR, exist_ok=True)
|
||||
await self._start_webhook_server()
|
||||
|
||||
# ⚡ Enregistrement des vues persistantes (boutons panel)
|
||||
# Sans ça, après un redémarrage les boutons "Remplir le formulaire"
|
||||
# dans les anciens messages de panel ne fonctionnent plus.
|
||||
self.bot.add_view(BugReportView(self))
|
||||
self.bot.add_view(FeatureSuggestionView(self))
|
||||
kuby_logger.info("✅ Vues persistantes BugReport enregistrées (open_bug_form, open_suggestion_form)")
|
||||
|
||||
async def _start_webhook_server(self):
|
||||
try:
|
||||
app = web.Application()
|
||||
app.router.add_post('/bot_event', self.handle_bot_event)
|
||||
self.runner = web.AppRunner(app)
|
||||
await self.runner.setup()
|
||||
self.site = web.TCPSite(self.runner, '127.0.0.1', 5001, reuse_address=True)
|
||||
await self.site.start()
|
||||
kuby_logger.info("Internal Bot Webhook Server started on 127.0.0.1:5001")
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Failed to start Internal Bot Webhook Server: {e}")
|
||||
|
||||
def cog_unload(self):
|
||||
self.check_gitlab_issues.cancel()
|
||||
|
||||
@tasks.loop(minutes=30)
|
||||
async def check_gitlab_issues(self):
|
||||
"""Vérifie périodiquement si des issues suivies ont été fermées"""
|
||||
if not os.path.exists(REPORTS_FILE):
|
||||
return
|
||||
if hasattr(self, 'runner'):
|
||||
async def cleanup():
|
||||
try:
|
||||
if hasattr(self, 'site'):
|
||||
await self.site.stop()
|
||||
await self.runner.cleanup()
|
||||
kuby_logger.info("Internal Bot Webhook Server stopped and cleaned up.")
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Error during webhook server cleanup: {e}")
|
||||
self.bot.loop.create_task(cleanup())
|
||||
|
||||
async def handle_bot_event(self, request):
|
||||
"""Gère les webhooks (adapté pour GitLab ET Forgejo)"""
|
||||
try:
|
||||
with open(REPORTS_FILE, "r") as f:
|
||||
reports = json.load(f)
|
||||
|
||||
if not reports:
|
||||
return
|
||||
if not self.bot.is_ready():
|
||||
kuby_logger.info("Webhook received but bot is not ready yet. Waiting up to 10s...")
|
||||
try:
|
||||
await asyncio.wait_for(self.bot.wait_until_ready(), timeout=10.0)
|
||||
except asyncio.TimeoutError:
|
||||
kuby_logger.warning("Bot still not ready after 10s. Proceeding anyway.")
|
||||
|
||||
issues_to_remove = []
|
||||
for issue_iid, user_id in reports.items():
|
||||
issue = await gitlab_client.get_issue(int(issue_iid))
|
||||
if issue and issue.get("state") == "closed":
|
||||
# L'issue est fermée ! On prévient l'utilisateur
|
||||
user = self.bot.get_user(user_id)
|
||||
if user:
|
||||
try:
|
||||
embed = discord.Embed(
|
||||
title="🛠️ Bug Résolu !",
|
||||
description=f"Bonne nouvelle ! Le bug que vous avez signalé (**{issue.get('title')}**) a été marqué comme terminé sur GitLab.",
|
||||
color=discord.Color.green()
|
||||
)
|
||||
embed.add_field(name="Statut", value="Terminé / Résolu")
|
||||
embed.add_field(name="Prochaine étape", value="La mise à jour arrive prochainement !")
|
||||
embed.set_footer(text="Merci de votre aide pour améliorer le bot.")
|
||||
await user.send(embed=embed)
|
||||
issues_to_remove.append(issue_iid)
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Impossible d'envoyer un MP à {user_id}: {e}")
|
||||
else:
|
||||
# On ne peut pas trouver l'user, on retire quand même pour ne pas boucler
|
||||
issues_to_remove.append(issue_iid)
|
||||
payload = await request.json()
|
||||
event_type = payload.get("object_kind")
|
||||
|
||||
if issues_to_remove:
|
||||
for iid in issues_to_remove:
|
||||
del reports[iid]
|
||||
with open(REPORTS_FILE, "w") as f:
|
||||
json.dump(reports, f, indent=4)
|
||||
if event_type not in ["issue", "note"]:
|
||||
kuby_logger.info("Webhook ignoré : type d’événement non reconnu")
|
||||
return web.json_response({"status": "ignored"}, status=200)
|
||||
|
||||
# Récupère l'IID de l'issue
|
||||
issue_iid = str(
|
||||
payload.get("object_attributes", {}).get("iid") or
|
||||
payload.get("issue", {}).get("iid")
|
||||
)
|
||||
|
||||
if not issue_iid:
|
||||
kuby_logger.warning("Webhook ignoré : aucun identifiant d’issue trouvé dans le payload")
|
||||
return web.json_response({"status": "no issue id"}, status=200)
|
||||
|
||||
issue_data = self.issue_data.get(issue_iid)
|
||||
if not issue_data:
|
||||
return web.json_response({"status": "untracked issue"}, status=200)
|
||||
|
||||
user_id = issue_data.get("requester_id")
|
||||
if not user_id:
|
||||
return web.json_response({"status": "no requester"}, status=200)
|
||||
|
||||
user = self.bot.get_user(user_id)
|
||||
if not user:
|
||||
try:
|
||||
user = await self.bot.fetch_user(user_id)
|
||||
except:
|
||||
return web.json_response({"status": "user not found"}, status=200)
|
||||
|
||||
# 🕵️ 3. RÉCUPÉRATION DU TITRE
|
||||
issue_title = (
|
||||
payload.get("object_attributes", {}).get("title") or
|
||||
payload.get("issue", {}).get("title", f"#{issue_iid}")
|
||||
)
|
||||
|
||||
if event_type == "note":
|
||||
# ⚡ NOUVEAU: Notifie les commentaires via le webhook (instantané)
|
||||
note_attr = payload.get("object_attributes", {})
|
||||
is_system = note_attr.get("system", False)
|
||||
|
||||
if not is_system:
|
||||
note_body = (
|
||||
note_attr.get("note") or
|
||||
note_attr.get("body") or
|
||||
payload.get("note", "") or ""
|
||||
)
|
||||
author_name = payload.get("user", {}).get("name", "Développeur")
|
||||
|
||||
if note_body:
|
||||
try:
|
||||
embed = disnake.Embed(
|
||||
title="💬 Nouveau commentaire sur votre signalement",
|
||||
description=f"Le développeur a répondu à votre rapport **{issue_title}** :\n\n>>> {note_body}\n\n✍️ **Par :** {author_name}",
|
||||
color=disnake.Color.orange()
|
||||
)
|
||||
embed.set_thumbnail(url=self.bot.user.display_avatar.url)
|
||||
await user.send(embed=embed)
|
||||
kuby_logger.info(f"Commentaire GitLab envoyé à {user} pour l'issue #{issue_iid}")
|
||||
except (disnake.Forbidden, disnake.HTTPException) as e:
|
||||
kuby_logger.warning(f"Impossible d'envoyer le DM à {user}: {e}")
|
||||
if dev and dev.id != user.id:
|
||||
try:
|
||||
await dev.send(f"⚠️ [DM BLOQUÉ] Impossible d'envoyer le commentaire à {user} pour l'issue #{issue_iid}")
|
||||
except:
|
||||
pass
|
||||
|
||||
elif event_type == "issue":
|
||||
# 🕵️ 5. GESTION DES CLÔTURES (Optionnel : si tu fermes le ticket sur Forgejo)
|
||||
action = payload.get("object_attributes", {}).get("action") or payload.get("action")
|
||||
state = payload.get("object_attributes", {}).get("state") or payload.get("issue", {}).get("state")
|
||||
|
||||
is_closing_now = action in ["close", "closed"] or state == "closed"
|
||||
|
||||
if is_closing_now:
|
||||
try:
|
||||
embed = disnake.Embed(
|
||||
title="🛠️ Bug / Suggestion Terminé(e) !",
|
||||
description=f"Bonne nouvelle ! Votre signalement **{issue_title}** a été marqué comme terminé ou résolu.\n\n🚀 **Prochaine étape :** La mise à jour arrive prochainement !\n✨ Merci pour votre aide !",
|
||||
color=disnake.Color.green()
|
||||
)
|
||||
if self.bot.user.display_avatar:
|
||||
embed.set_thumbnail(url=self.bot.user.display_avatar.url)
|
||||
await user.send(embed=embed)
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Erreur notification clôture: {e}")
|
||||
|
||||
return web.json_response({"status": "success"}, status=200)
|
||||
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Erreur lors du cycle de vérification GitLab: {e}")
|
||||
kuby_logger.error(f"Error handling bot event: {e}")
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
@app_commands.command(name="signaler_bug", description="Signaler un bug aux développeurs")
|
||||
@app_commands.choices(priority=[
|
||||
app_commands.Choice(name="Basse", value="Low"),
|
||||
app_commands.Choice(name="Normale", value="Normal"),
|
||||
app_commands.Choice(name="Haute", value="High"),
|
||||
app_commands.Choice(name="Urgente", value="Urgent"),
|
||||
])
|
||||
async def signaler_bug(self, interaction: discord.Interaction, priority: app_commands.Choice[str]):
|
||||
await interaction.response.send_modal(BugReportModal(priority))
|
||||
@commands.slash_command(name="signaler_bug", description="Signaler un bug aux développeurs")
|
||||
@check_premium_tier()
|
||||
async def signifier_bug(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
modal = BugReportModal(self)
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(BugReport(bot))
|
||||
@commands.slash_command(name="suggerer_fonctionnalite", description="Proposer une nouvelle fonctionnalité")
|
||||
@check_premium_tier()
|
||||
async def suggerer_fonctionnalite(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
modal = FeatureSuggestionModal(self)
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
|
||||
def setup(bot):
|
||||
bot.add_cog(BugReport(bot))
|
||||
|
|
|
|||
313
commandes/convocation.py
Executable file
313
commandes/convocation.py
Executable file
|
|
@ -0,0 +1,313 @@
|
|||
import disnake
|
||||
from disnake.ext import commands
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional, Dict
|
||||
import datetime
|
||||
import asyncio
|
||||
import logging
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
kuby_logger = logging.getLogger("KubyBot")
|
||||
|
||||
# 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):
|
||||
return {}
|
||||
try:
|
||||
with open(filepath, "r", encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
return {}
|
||||
|
||||
def save_json(filepath: str, data: Dict):
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
class Convocation(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.settings = load_json(CONV_SETTINGS_FILE)
|
||||
self.backups = load_json(CONV_BACKUPS_FILE)
|
||||
|
||||
def get_guild_settings(self, guild_id: int) -> Dict:
|
||||
return self.settings.get(str(guild_id), {})
|
||||
|
||||
def save_guild_settings(self, guild_id: int, guild_settings: Dict):
|
||||
self.settings[str(guild_id)] = guild_settings
|
||||
save_json(CONV_SETTINGS_FILE, self.settings)
|
||||
|
||||
def get_backup(self, guild_id: int, user_id: int) -> Optional[Dict]:
|
||||
key = f"{guild_id}_{user_id}"
|
||||
return self.backups.get(key)
|
||||
|
||||
def save_backup(self, guild_id: int, user_id: int, backup_data: Dict):
|
||||
key = f"{guild_id}_{user_id}"
|
||||
self.backups[key] = backup_data
|
||||
save_json(CONV_BACKUPS_FILE, self.backups)
|
||||
|
||||
def clear_backup(self, guild_id: int, user_id: int):
|
||||
key = f"{guild_id}_{user_id}"
|
||||
if key in self.backups:
|
||||
del self.backups[key]
|
||||
save_json(CONV_BACKUPS_FILE, self.backups)
|
||||
|
||||
@commands.slash_command(name="convocation", description="Gère les convocations de membres")
|
||||
@check_premium_tier()
|
||||
@commands.has_permissions(manage_roles=True)
|
||||
async def convocation_group(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
pass
|
||||
|
||||
@convocation_group.sub_command(name="convoquer", description="Convoque un membre et verrouille ses accès")
|
||||
async def convoquer(self, interaction: disnake.ApplicationCommandInteraction,
|
||||
membre: disnake.Member = commands.Param(description="Le membre à convoquer"),
|
||||
raison: str = commands.Param(description="La raison de la convocation"),
|
||||
heure: str = commands.Param(description="L'heure de la convocation (ex: 14:30)")):
|
||||
guild_id = interaction.guild.id
|
||||
settings = self.get_guild_settings(guild_id)
|
||||
role_conv_id = settings.get("role_id")
|
||||
log_channel_id = settings.get("log_channel_id")
|
||||
attente_id = settings.get("attente_id") # Salon d'attente à ignorer
|
||||
admin_ids = settings.get("admin_ids", [])
|
||||
|
||||
if not role_conv_id:
|
||||
await interaction.response.send_message("❌ Le rôle de convocation n'est pas configuré. Utilisez `/convocation config`.", ephemeral=True)
|
||||
return
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
try:
|
||||
# --- BACKUP ---
|
||||
bot_top_role = interaction.guild.me.top_role
|
||||
backup_data = {
|
||||
"roles": [r.id for r in membre.roles if not r.is_default() and r.id != role_conv_id and r.position < bot_top_role.position],
|
||||
"overrides": {}
|
||||
}
|
||||
|
||||
# 1. Backup and Remove current roles
|
||||
current_roles = [interaction.guild.get_role(rid) for rid in backup_data["roles"] if interaction.guild.get_role(rid)]
|
||||
if current_roles:
|
||||
try:
|
||||
await membre.remove_roles(*current_roles, reason=f"Convocation par {interaction.author}")
|
||||
except disnake.Forbidden:
|
||||
await interaction.followup.send("⚠️ Je n'ai pas pu retirer certains rôles (Permission refusée).", ephemeral=True)
|
||||
|
||||
# 2. Dynamic Lockdown: Loop through ALL channels
|
||||
semaphore = asyncio.Semaphore(10)
|
||||
|
||||
async def lock_channel(channel):
|
||||
async with semaphore:
|
||||
# On ignore le salon d'attente et ses parents s'il y en a
|
||||
if attente_id and (channel.id == attente_id or (hasattr(channel, 'category') and channel.category and channel.category.id == attente_id)):
|
||||
return False
|
||||
|
||||
# Check existing override for the member
|
||||
overwrites = channel.overwrites_for(membre)
|
||||
if not overwrites.is_empty():
|
||||
# Save existing override
|
||||
backup_data["overrides"][str(channel.id)] = {k: v for k, v in dict(overwrites).items() if v is not None}
|
||||
|
||||
# Apply Lockdown override for the member
|
||||
try:
|
||||
await channel.set_permissions(membre, view_channel=False, reason="Convocation Lockdown")
|
||||
return True
|
||||
except disnake.Forbidden:
|
||||
return False
|
||||
except Exception as e:
|
||||
kuby_logger.warning(f"⚠️ [Convocation] Impossible de verrouiller {channel.name}: {e}")
|
||||
return False
|
||||
|
||||
tasks = [lock_channel(c) for c in interaction.guild.channels]
|
||||
results = await asyncio.gather(*tasks)
|
||||
lockdown_count = sum(1 for r in results if r is True)
|
||||
|
||||
self.save_backup(guild_id, membre.id, backup_data)
|
||||
|
||||
# 3. Add Convocation role
|
||||
role_conv = interaction.guild.get_role(role_conv_id)
|
||||
if role_conv:
|
||||
if role_conv.position >= bot_top_role.position:
|
||||
await interaction.followup.send(f"⚠️ Je ne peux pas donner le rôle {role_conv.mention} car il est plus haut que moi !", ephemeral=True)
|
||||
else:
|
||||
try:
|
||||
await membre.add_roles(role_conv, reason="Début de convocation")
|
||||
except disnake.Forbidden:
|
||||
await interaction.followup.send(f"⚠️ Impossible de donner le rôle {role_conv.name} (Permission refusée).", ephemeral=True)
|
||||
|
||||
# 4. Embeds & Logs (Inchangés mais inclus pour la cohérence)
|
||||
embed_conv = disnake.Embed(
|
||||
title="CONVOCATION | 🔖",
|
||||
description=f"> Bonjour {membre.mention}, vous êtes **convoqué** par {interaction.author.mention}. Voici les **détails** :",
|
||||
color=0x0099ff,
|
||||
timestamp=disnake.utils.utcnow()
|
||||
)
|
||||
embed_conv.add_field(name="**Raison de la convocation :**", value=f"➜ {raison}", inline=False)
|
||||
embed_conv.add_field(name="**Heure de la convocation :**", value=f"➜ {heure}", inline=False)
|
||||
embed_conv.add_field(name="\u200B", value=(
|
||||
"> Merci de vous présenter dans le vocal attente **5 minutes** avant l'heure.\n\n"
|
||||
"> La **levée** de suspension se fera uniquement à la **fin** de la convocation."
|
||||
), inline=False)
|
||||
embed_conv.set_footer(text=f"Gestion de {interaction.guild.name}")
|
||||
|
||||
embed_log = disnake.Embed(
|
||||
title="📋 CONVOCATION ENVOYÉE",
|
||||
color=0xff9900,
|
||||
timestamp=disnake.utils.utcnow()
|
||||
)
|
||||
embed_log.add_field(name="👤 Membre convoqué", value=f"{membre.mention} ({membre.id})", inline=True)
|
||||
embed_log.add_field(name="👮 Convoqué par", value=f"{interaction.author.mention}", inline=True)
|
||||
embed_log.add_field(name="📄 Raison", value=raison, inline=False)
|
||||
embed_log.add_field(name="🕒 Heure", value=heure, inline=False)
|
||||
|
||||
# DM
|
||||
dm_sent = False
|
||||
try:
|
||||
await membre.send(embed=embed_conv)
|
||||
dm_sent = True
|
||||
except disnake.Forbidden:
|
||||
dm_sent = False
|
||||
|
||||
# Admin & Channel Logs
|
||||
for admin_id in admin_ids:
|
||||
try:
|
||||
admin = await self.bot.fetch_user(admin_id)
|
||||
await admin.send(embed=embed_log)
|
||||
except: pass
|
||||
|
||||
if log_channel_id:
|
||||
chan = interaction.guild.get_channel(log_channel_id)
|
||||
if chan: await chan.send(embed=embed_log)
|
||||
|
||||
await interaction.followup.send(f"✅ Convocation envoyée à {membre.mention}. ({lockdown_count} salons verrouillés) " +
|
||||
("" if dm_sent else "(⚠️ DM impossible)"), ephemeral=True)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await interaction.followup.send(f"❌ Une erreur critique est survenue : {e}", ephemeral=True)
|
||||
|
||||
@convocation_group.sub_command(name="lever", description="Lève la convocation d'un membre et restaure ses accès")
|
||||
async def lever(self, interaction: disnake.ApplicationCommandInteraction,
|
||||
membre: disnake.Member = commands.Param(description="Le membre à libérer")):
|
||||
guild_id = interaction.guild.id
|
||||
settings = self.get_guild_settings(guild_id)
|
||||
role_conv_id = settings.get("role_id")
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
backup_data = self.get_backup(guild_id, membre.id)
|
||||
if not backup_data:
|
||||
await interaction.followup.send("⚠️ Aucune donnée de convocation trouvée pour ce membre.", ephemeral=True)
|
||||
return
|
||||
|
||||
# 1. Remove Convocation role & Restore original roles FIRST
|
||||
try:
|
||||
if role_conv_id:
|
||||
role_conv = interaction.guild.get_role(role_conv_id)
|
||||
if role_conv and role_conv in membre.roles:
|
||||
await membre.remove_roles(role_conv, reason="Fin de convocation")
|
||||
|
||||
role_ids = backup_data.get("roles", [])
|
||||
if role_ids:
|
||||
restored_roles = []
|
||||
for rid in role_ids:
|
||||
role = interaction.guild.get_role(rid)
|
||||
if role and role.position < interaction.guild.me.top_role.position:
|
||||
restored_roles.append(role)
|
||||
|
||||
if restored_roles:
|
||||
await membre.add_roles(*restored_roles, reason="Restauration fin de convocation")
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"❌ [Convocation] Erreur lors de la restauration des rôles pour {membre}: {e}")
|
||||
|
||||
# 2. Restore channel overrides (Parallel)
|
||||
semaphore = asyncio.Semaphore(10)
|
||||
overrides = backup_data.get("overrides", {})
|
||||
|
||||
async def restore_channel(channel):
|
||||
async with semaphore:
|
||||
chan_id_str = str(channel.id)
|
||||
try:
|
||||
if chan_id_str in overrides:
|
||||
# Restore previous override
|
||||
ov_data = overrides[chan_id_str]
|
||||
overwrite = disnake.PermissionOverwrite(**ov_data)
|
||||
await channel.set_permissions(membre, overwrite=overwrite, reason="Restauration fin de convocation")
|
||||
return "restored"
|
||||
elif membre in channel.overwrites:
|
||||
# Clear our lockdown override only if it exists
|
||||
await channel.set_permissions(membre, overwrite=None, reason="Fin de convocation - Cleanup")
|
||||
return "cleared"
|
||||
except disnake.Forbidden:
|
||||
return "forbidden"
|
||||
except Exception as e:
|
||||
kuby_logger.warning(f"⚠️ [Convocation] Erreur restauration {channel.name}: {e}")
|
||||
return "error"
|
||||
return "skipped"
|
||||
|
||||
tasks = [restore_channel(c) for c in interaction.guild.channels]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
restore_count = sum(1 for r in results if r == "restored")
|
||||
clear_count = sum(1 for r in results if r == "cleared")
|
||||
forbidden_count = sum(1 for r in results if r == "forbidden")
|
||||
|
||||
kuby_logger.info(f"🔓 [Convocation] Restauration terminée pour {membre}: {restore_count} restaurés, {clear_count} nettoyés, {forbidden_count} échecs permissions.")
|
||||
|
||||
self.clear_backup(guild_id, membre.id)
|
||||
|
||||
# 4. Notification
|
||||
embed = disnake.Embed(
|
||||
title="🔓 CONVOCATION TERMINÉE",
|
||||
description=f"Votre convocation sur **{interaction.guild.name}** est terminée. Vos accès ont été restaurés.",
|
||||
color=disnake.Color.green()
|
||||
)
|
||||
try:
|
||||
await membre.send(embed=embed)
|
||||
except: pass
|
||||
|
||||
await interaction.followup.send(f"✅ Convocation levée pour {membre.mention}. Access restaurés.", ephemeral=True)
|
||||
|
||||
@convocation_group.sub_command(name="config", description="Configure le système de convocation")
|
||||
async def config(self, interaction: disnake.ApplicationCommandInteraction,
|
||||
role: disnake.Role = commands.Param(description="Le rôle de convocation (Muet/Convocation)"),
|
||||
channel: disnake.TextChannel = commands.Param(None, description="Le salon de logs"),
|
||||
attente: disnake.abc.GuildChannel = commands.Param(None, description="Salon (ou catégorie) d'attente à laisser visible"),
|
||||
admins: str = commands.Param(None, description="Liste d'IDs d'admins à notifier (séparés par des espaces)")):
|
||||
guild_id = interaction.guild.id
|
||||
settings = self.get_guild_settings(guild_id)
|
||||
|
||||
settings["role_id"] = role.id
|
||||
if channel:
|
||||
settings["log_channel_id"] = channel.id
|
||||
if attente:
|
||||
settings["attente_id"] = attente.id
|
||||
|
||||
if admins:
|
||||
admin_list = []
|
||||
for uid in admins.split():
|
||||
if uid.isdigit():
|
||||
admin_list.append(int(uid))
|
||||
settings["admin_ids"] = admin_list
|
||||
|
||||
self.save_guild_settings(guild_id, settings)
|
||||
|
||||
embed = disnake.Embed(title="⚙️ Configuration Convocation Mise à jour", color=disnake.Color.blue())
|
||||
embed.add_field(name="Rôle", value=role.mention)
|
||||
embed.add_field(name="Salon Logs", value=channel.mention if channel else "Non défini")
|
||||
embed.add_field(name="Salon/Cat d'attente", value=attente.mention if attente else "Non défini")
|
||||
embed.add_field(name="Admins notifiés", value=str(len(settings.get("admin_ids", []))))
|
||||
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
def setup(bot):
|
||||
bot.add_cog(Convocation(bot))
|
||||
212
commandes/gitlab_integration.py
Normal file
212
commandes/gitlab_integration.py
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
import aiohttp
|
||||
import disnake
|
||||
from disnake.ext import commands, tasks
|
||||
|
||||
# Configuration des variables d'environnement pour Forgejo
|
||||
FORGEJO_TOKEN = os.getenv("FORGEJO_TOKEN")
|
||||
FORGEJO_OWNER = os.getenv("FORGEJO_OWNER") # Exemple: "Gameur" ou "Omega_Kube"
|
||||
FORGEJO_REPO = os.getenv("FORGEJO_REPO") # Exemple: "kuby"
|
||||
BASE_URL = "https://omegakubeserv.tail951d2f.ts.net/api/v1"
|
||||
|
||||
if not FORGEJO_TOKEN or not FORGEJO_OWNER or not FORGEJO_REPO:
|
||||
raise RuntimeError("Forgejo variables (FORGEJO_TOKEN, FORGEJO_OWNER, FORGEJO_REPO) must be set.")
|
||||
|
||||
# Fichiers de persistance mis à jour pour Forgejo
|
||||
ISSUE_TRACK_FILE = os.path.join(os.path.dirname(__file__), "forgejo_issues.json")
|
||||
|
||||
def load_issue_tracking() -> Dict[str, Any]:
|
||||
tracking = {}
|
||||
if os.path.exists(ISSUE_TRACK_FILE):
|
||||
try:
|
||||
with open(ISSUE_TRACK_FILE, "r", encoding="utf-8") as f:
|
||||
tracking = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Erreur chargement forgejo_issues.json: {e}")
|
||||
return tracking
|
||||
|
||||
def save_issue_tracking(data: Dict[str, Any]) -> None:
|
||||
with open(ISSUE_TRACK_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
class ForgejoIntegration(commands.Cog):
|
||||
"""Cog fournissant des commandes slash pour créer des tickets sur Forgejo et notifier les utilisateurs."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.issue_data: Dict[str, Any] = load_issue_tracking()
|
||||
self.check_new_comments.start()
|
||||
|
||||
def cog_unload(self):
|
||||
self.check_new_comments.cancel()
|
||||
|
||||
async def _create_forgejo_issue(self, title: str, description: str, labels: List[str], requester_id: int = None) -> Optional[Dict[str, Any]]:
|
||||
"""Crée un ticket sur Forgejo et le track automatiquement."""
|
||||
url = f"{BASE_URL}/repos/{FORGEJO_OWNER}/{FORGEJO_REPO}/issues"
|
||||
headers = {
|
||||
"Authorization": f"token {FORGEJO_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"title": title,
|
||||
"body": description, # GitLab utilisait 'description', Forgejo utilise 'body'
|
||||
"labels": labels, # Forgejo accepte directement une liste ['bug', 'bot']
|
||||
}
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, json=payload) as resp:
|
||||
resp.raise_for_status()
|
||||
result = await resp.json()
|
||||
|
||||
if requester_id:
|
||||
issue_number = str(result["number"]) # 'iid' devient 'number'
|
||||
self.issue_data[issue_number] = {
|
||||
"requester_id": requester_id,
|
||||
"last_note_id": 0,
|
||||
"type": "discord-command"
|
||||
}
|
||||
save_issue_tracking(self.issue_data)
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"❌ Erreur création issue Forgejo: {e}")
|
||||
return None
|
||||
|
||||
async def _get_issue_comments(self, issue_number: int) -> List[Dict[str, Any]]:
|
||||
"""Récupère tous les commentaires d'un ticket spécifique."""
|
||||
url = f"{BASE_URL}/repos/{FORGEJO_OWNER}/{FORGEJO_REPO}/issues/{issue_number}/comments"
|
||||
headers = {"Authorization": f"token {FORGEJO_TOKEN}"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
async def _notify_requester(self, requester_id: int, issue_number: int, comment: Dict[str, Any]):
|
||||
"""Envoie un DM privé à l'utilisateur lors d'un nouveau commentaire."""
|
||||
user = await self.bot.fetch_user(requester_id)
|
||||
author = comment.get("user", {}).get("username", "Quelqu'un") # 'author.name' devient 'user.username'
|
||||
body = comment.get("body", "")
|
||||
|
||||
embed = disnake.Embed(
|
||||
title=f"Nouveau commentaire sur votre demande Forgejo (#{issue_number})",
|
||||
description=body,
|
||||
color=disnake.Color.brand_green(),
|
||||
timestamp=datetime.fromisoformat(comment["created_at"].replace("Z", "+00:00")),
|
||||
)
|
||||
embed.set_footer(text=f"Commentaire de {author}")
|
||||
try:
|
||||
await user.send(embed=embed)
|
||||
except disnake.HTTPException:
|
||||
pass
|
||||
|
||||
@tasks.loop(minutes=1)
|
||||
async def check_new_comments(self):
|
||||
"""Boucle de vérification des commentaires Forgejo."""
|
||||
for issue_num_str, info in list(self.issue_data.items()):
|
||||
issue_number = int(issue_num_str)
|
||||
last_known_id = info.get("last_note_id", 0)
|
||||
requester_id = info.get("requester_id")
|
||||
|
||||
try:
|
||||
comments = await self._get_issue_comments(issue_number)
|
||||
if not comments:
|
||||
continue
|
||||
|
||||
missing_comments = [c for c in comments if c["id"] > last_known_id]
|
||||
if missing_comments:
|
||||
for comment in missing_comments:
|
||||
await self._notify_requester(requester_id, issue_number, comment)
|
||||
|
||||
new_last_id = max(c["id"] for c in comments)
|
||||
self.issue_data[issue_num_str]["last_note_id"] = new_last_id
|
||||
except Exception as e:
|
||||
print(f"⚠️ Erreur vérification issue #{issue_number}: {e}")
|
||||
continue
|
||||
save_issue_tracking(self.issue_data)
|
||||
|
||||
@commands.slash_command(name="recuperer_suivi_issues", description="Récupère le suivi des commentaires Forgejo après un incident")
|
||||
async def recuperer_suivi(self, inter: disnake.ApplicationCommandInteraction):
|
||||
await inter.response.defer(ephemeral=True)
|
||||
updated_count = 0
|
||||
|
||||
for issue_num_str, info in list(self.issue_data.items()):
|
||||
issue_number = int(issue_num_str)
|
||||
try:
|
||||
comments = await self._get_issue_comments(issue_number)
|
||||
if comments:
|
||||
latest_id = max(c["id"] for c in comments)
|
||||
if latest_id > info.get("last_note_id", 0):
|
||||
self.issue_data[issue_num_str]["last_note_id"] = latest_id
|
||||
updated_count += 1
|
||||
except Exception as e:
|
||||
print(f"⚠️ Erreur récupération issue #{issue_number}: {e}")
|
||||
continue
|
||||
|
||||
save_issue_tracking(self.issue_data)
|
||||
await inter.edit_original_message(
|
||||
content=f"✅ Suivi récupéré pour **{updated_count}/{len(self.issue_data)}** issues."
|
||||
)
|
||||
|
||||
@commands.slash_command(name="suggerer_fonctionnalite", description="Suggérer une fonctionnalité pour le projet")
|
||||
async def suggere_fonctionnalite(self, inter: disnake.ApplicationCommandInteraction):
|
||||
await inter.response.send_modal(modal=FeatureModal(self))
|
||||
|
||||
@commands.slash_command(name="signaler_bug", description="Signaler un bug sur Kuby")
|
||||
async def signaler_bug(self, inter: disnake.ApplicationCommandInteraction):
|
||||
await inter.response.send_modal(modal=BugModal(self))
|
||||
|
||||
|
||||
# ---------- Modals Acteurs ----------
|
||||
class FeatureModal(disnake.Modal):
|
||||
def __init__(self, cog: ForgejoIntegration):
|
||||
components = [
|
||||
disnake.ui.TextInput(label="Titre de la fonctionnalité", custom_id="title", max_length=100, required=True),
|
||||
disnake.ui.TextInput(label="Description détaillée", custom_id="description", style=disnake.TextInputStyle.paragraph, max_length=2000, required=True),
|
||||
]
|
||||
super().__init__(title="Suggestion de fonctionnalité", custom_id="feature_modal", components=components)
|
||||
self.cog = cog
|
||||
|
||||
async def callback(self, inter: disnake.ModalInteraction):
|
||||
title = inter.text_values["title"]
|
||||
description = inter.text_values["description"]
|
||||
await inter.response.defer(ephemeral=True)
|
||||
|
||||
issue = await self.cog._create_forgejo_issue(
|
||||
title=title, description=description, labels=["feature", "discord-bot"], requester_id=inter.author.id
|
||||
)
|
||||
if issue:
|
||||
url = f"https://omegakubeserv.tail951d2f.ts.net/{FORGEJO_OWNER}/{FORGEJO_REPO}/issues/{issue['number']}"
|
||||
await inter.edit_original_message(content=f"✅ Fonctionnalité créée : [{title}]({url})")
|
||||
else:
|
||||
await inter.edit_original_message(content="❌ Erreur lors de la création sur Forgejo.")
|
||||
|
||||
|
||||
class BugModal(disnake.Modal):
|
||||
def __init__(self, cog: ForgejoIntegration):
|
||||
components = [
|
||||
disnake.ui.TextInput(label="Titre du bug", custom_id="title", max_length=100, required=True),
|
||||
disnake.ui.TextInput(label="Description et étapes", style=disnake.TextInputStyle.paragraph, custom_id="description", max_length=2000, required=True),
|
||||
]
|
||||
super().__init__(title="Signalement de bug", custom_id="bug_modal", components=components)
|
||||
self.cog = cog
|
||||
|
||||
async def callback(self, inter: disnake.ModalInteraction):
|
||||
title = inter.text_values["title"]
|
||||
description = inter.text_values["description"]
|
||||
await inter.response.defer(ephemeral=True)
|
||||
|
||||
issue = await self.cog._create_forgejo_issue(
|
||||
title=title, description=description, labels=["bug", "discord-bot"], requester_id=inter.author.id
|
||||
)
|
||||
if issue:
|
||||
url = f"https://omegakubeserv.tail951d2f.ts.net/{FORGEJO_OWNER}/{FORGEJO_REPO}/issues/{issue['number']}"
|
||||
await inter.edit_original_message(content=f"✅ Bug signalé : [{title}]({url})")
|
||||
else:
|
||||
await inter.edit_original_message(content="❌ Erreur lors du signalement sur Forgejo.")
|
||||
|
||||
def setup(bot: commands.Bot):
|
||||
bot.add_cog(ForgejoIntegration(bot))
|
||||
47
commandes/gitlab_issues.json
Normal file
47
commandes/gitlab_issues.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"4": {
|
||||
"requester_id": 971446412690722826,
|
||||
"last_note_id": 0,
|
||||
"type": "legacy_report"
|
||||
},
|
||||
"5": {
|
||||
"requester_id": 971446412690722826,
|
||||
"last_note_id": 0,
|
||||
"type": "legacy_report"
|
||||
},
|
||||
"15": {
|
||||
"requester_id": 971446412690722826,
|
||||
"last_note_id": 0,
|
||||
"type": "legacy_report"
|
||||
},
|
||||
"153": {
|
||||
"requester_id": 534003230435442690,
|
||||
"last_note_id": 0,
|
||||
"type": "legacy_report"
|
||||
},
|
||||
"160": {
|
||||
"requester_id": 971446412690722826,
|
||||
"last_note_id": 0,
|
||||
"type": "legacy_report"
|
||||
},
|
||||
"161": {
|
||||
"requester_id": 971446412690722826,
|
||||
"last_note_id": 0,
|
||||
"type": "legacy_report"
|
||||
},
|
||||
"162": {
|
||||
"requester_id": 971446412690722826,
|
||||
"last_note_id": 0,
|
||||
"type": "legacy_report"
|
||||
},
|
||||
"164": {
|
||||
"requester_id": 971446412690722826,
|
||||
"last_note_id": 0,
|
||||
"type": "legacy_report"
|
||||
},
|
||||
"None": {
|
||||
"requester_id": 971446412690722826,
|
||||
"last_note_id": 0,
|
||||
"type": "manual-report"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,58 @@
|
|||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
import disnake
|
||||
from disnake.ext import commands
|
||||
import sqlite3
|
||||
import aiohttp
|
||||
import io
|
||||
import os
|
||||
import logging
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from datetime import datetime, timezone
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
kuby_logger = logging.getLogger("KubyBot")
|
||||
|
||||
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):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
# On s'assure que la table existe au cas où (normalement gérée par welcome.py)
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS server_configs (
|
||||
server_id INTEGER PRIMARY KEY,
|
||||
welcome_channel INTEGER,
|
||||
goodbye_channel INTEGER,
|
||||
welcome_dm_message TEXT,
|
||||
welcome_server_name TEXT,
|
||||
welcome_banner_background TEXT,
|
||||
welcome_channel_message TEXT,
|
||||
goodbye_server_name TEXT,
|
||||
goodbye_message TEXT,
|
||||
goodbye_banner_background TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# Migration pour ajouter les colonnes si elles n'existent pas
|
||||
cursor.execute("PRAGMA table_info(server_configs)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
if 'goodbye_server_name' not in columns:
|
||||
cursor.execute('ALTER TABLE server_configs ADD COLUMN goodbye_server_name TEXT')
|
||||
|
||||
if 'goodbye_message' not in columns:
|
||||
cursor.execute('ALTER TABLE server_configs ADD COLUMN goodbye_message TEXT')
|
||||
|
||||
if 'goodbye_banner_background' not in columns:
|
||||
cursor.execute('ALTER TABLE server_configs ADD COLUMN goodbye_banner_background TEXT')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def humanize_time_delta(self, date):
|
||||
if date is None: return "date inconnue"
|
||||
|
|
@ -41,69 +82,251 @@ class Goodbye(commands.Cog):
|
|||
seconds = delta.seconds % 60
|
||||
return f"il y a {seconds} {'seconde' if seconds <= 1 else 'secondes'}"
|
||||
|
||||
async def create_goodbye_image(self, username, avatar_url):
|
||||
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()
|
||||
async def create_goodbye_image(self, username, avatar_url, server_name=None, banner_background=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"[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()
|
||||
|
||||
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)
|
||||
# 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)
|
||||
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}")
|
||||
|
||||
# Adapter le texte pour éviter les débordements et afficher correctement le nom
|
||||
line1_text = "AU REVOIR"
|
||||
line2_text = username.upper()
|
||||
line3_text = (server_name or "OMEGA KUBE").upper()
|
||||
|
||||
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))
|
||||
draw.text((325, 195), "OMEGA KUBE", font=font_large_omega, fill=(255,255,255))
|
||||
text_area_left = 300
|
||||
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
|
||||
|
||||
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)
|
||||
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]
|
||||
|
||||
img_byte_arr = io.BytesIO()
|
||||
background.save(img_byte_arr, format='PNG')
|
||||
img_byte_arr.seek(0)
|
||||
return img_byte_arr
|
||||
font1, w1, h1 = fit_font(font_large_path, line1_text, 65, text_area_width)
|
||||
font2, w2, h2 = fit_font(font_small_path, line2_text, 38, text_area_width)
|
||||
font3, w3, h3 = fit_font(font_large_path, line3_text, 65, text_area_width)
|
||||
|
||||
spacing_1_2 = 12
|
||||
spacing_2_3 = 12
|
||||
total_height = h1 + spacing_1_2 + h2 + spacing_2_3 + h3
|
||||
start_y = (background.height - total_height) // 2
|
||||
|
||||
y = start_y
|
||||
draw.text((text_area_center_x - w1 // 2, y), line1_text, font=font1, fill=(255, 255, 255))
|
||||
y += h1 + spacing_1_2
|
||||
draw.text((text_area_center_x - w2 // 2, y), line2_text, font=font2, fill=(255, 255, 255))
|
||||
y += h2 + spacing_2_3
|
||||
draw.text((text_area_center_x - w3 // 2, y), line3_text, font=font3, fill=(255, 255, 255))
|
||||
|
||||
# Ajouter l'avatar
|
||||
avatar = Image.open(io.BytesIO(avatar_bytes)).convert("RGBA").resize((224, 224), Image.LANCZOS)
|
||||
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:
|
||||
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 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}")
|
||||
|
||||
self._init_db()
|
||||
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 result and result[0]:
|
||||
channel = member.guild.get_channel(result[0])
|
||||
if channel:
|
||||
img = await self.create_goodbye_image(member.name, member.avatar.url if member.avatar else member.default_avatar.url)
|
||||
joined_message = self.humanize_time_delta(member.joined_at)
|
||||
if not result or not result[0]:
|
||||
kuby_logger.debug(f"[Goodbye] Aucune configuration de départ pour le serveur {member.guild.id}")
|
||||
return
|
||||
|
||||
goodbye_channel = result[0]
|
||||
server_name = result[1]
|
||||
custom_message = result[2]
|
||||
banner_background = result[3]
|
||||
|
||||
channel = member.guild.get_channel(goodbye_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
|
||||
)
|
||||
|
||||
embed = discord.Embed(title=f"{member} viens de nous quitter.",
|
||||
description="Nous espérons vous revoir bientôt <a:rain:1317973615714504806>",
|
||||
color=discord.Color.dark_magenta())
|
||||
embed.set_footer(text=f"Avait rejoint {joined_message}")
|
||||
embed.set_image(url="attachment://goodbye.png")
|
||||
await channel.send(embed=embed, file=discord.File(img, filename="goodbye.png"))
|
||||
# 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 <a:rain:1317973615714504806>"
|
||||
message_text = custom_message if custom_message else default_message
|
||||
|
||||
if img:
|
||||
embed = disnake.Embed(
|
||||
description=f"{member.mention} vient de nous quitter.\n\n{message_text}",
|
||||
color=0x9B59B6
|
||||
)
|
||||
embed.set_footer(text=f"avait rejoint · {joined_message}")
|
||||
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}",
|
||||
color=0x9B59B6
|
||||
)
|
||||
embed.set_footer(text=f"avait rejoint · {joined_message}")
|
||||
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)
|
||||
|
||||
@app_commands.command(name="setgoodbye", description="Définit le salon d'aurevoir")
|
||||
@app_commands.checks.has_permissions(administrator=True)
|
||||
async def set_goodbye(self, interaction: discord.Interaction, channel: discord.TextChannel):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''INSERT INTO server_configs (server_id, goodbye_channel)
|
||||
VALUES (?, ?) ON CONFLICT(server_id)
|
||||
DO UPDATE SET goodbye_channel=excluded.goodbye_channel''',
|
||||
(interaction.guild_id, channel.id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
await interaction.response.send_message(f"Salon d'aurevoir défini sur {channel.mention}", ephemeral=True)
|
||||
async def save_banner_attachment(self, attachment: disnake.Attachment, guild_id: int) -> str:
|
||||
"""Sauvegarde l'attachment et retourne le chemin du fichier"""
|
||||
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "banners"))
|
||||
os.makedirs(base_dir, exist_ok=True)
|
||||
|
||||
# Vérifier que c'est une image
|
||||
if not attachment.content_type or not attachment.content_type.startswith('image/'):
|
||||
raise ValueError("Le fichier doit être une image.")
|
||||
|
||||
# Vérifier le format PNG
|
||||
if not attachment.filename.lower().endswith('.png'):
|
||||
raise ValueError("Le fichier doit être au format PNG.")
|
||||
|
||||
# Vérifier les dimensions
|
||||
banner_path = os.path.join(base_dir, f"banner_goodbye_{guild_id}.png")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(attachment.url) as resp:
|
||||
if resp.status == 200:
|
||||
# Vérifier les dimensions avant de sauvegarder
|
||||
img_data = await resp.read()
|
||||
from io import BytesIO
|
||||
with Image.open(BytesIO(img_data)) as img:
|
||||
width, height = img.size
|
||||
if width != 1000 or height != 300:
|
||||
raise ValueError(f"Les dimensions doivent être 1000x300 pixels (votre image: {width}x{height})")
|
||||
|
||||
with open(banner_path, 'wb') as f:
|
||||
f.write(img_data)
|
||||
|
||||
return os.path.basename(banner_path)
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(Goodbye(bot))
|
||||
@commands.slash_command(name="setgoodbye", description="Définit le salon d'au revoir et les paramètres personnalisés")
|
||||
@check_premium_tier()
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def set_goodbye(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel, server_name: str = None, message: str = None, banner_file: disnake.Attachment = None):
|
||||
# ✅ DÉFÉRER LA RÉPONSE (le traitement peut prendre > 3s avec les images)
|
||||
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:
|
||||
error_message = f"❌ Erreur avec le fichier: {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}"
|
||||
|
||||
# Envoyer la réponse finale (déferrée)
|
||||
await interaction.followup.send(error_message, ephemeral=True)
|
||||
|
||||
def setup(bot):
|
||||
bot.add_cog(Goodbye(bot))
|
||||
269
commandes/invites.py
Normal file
269
commandes/invites.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import disnake
|
||||
from disnake.ext import commands
|
||||
import sqlite3
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
import asyncio
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
kuby_logger = logging.getLogger("KubyBot")
|
||||
|
||||
class Invites(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
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):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS member_invites (
|
||||
guild_id INTEGER,
|
||||
member_id INTEGER,
|
||||
inviter_id INTEGER,
|
||||
invite_code TEXT,
|
||||
PRIMARY KEY (guild_id, member_id)
|
||||
)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS invite_configs (
|
||||
guild_id INTEGER PRIMARY KEY,
|
||||
log_channel_id INTEGER
|
||||
)
|
||||
''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
async def update_invite_cache(self, guild):
|
||||
try:
|
||||
invites = await guild.invites()
|
||||
async with self.cache_lock:
|
||||
self.invite_cache[guild.id] = {invite.code: invite.uses for invite in invites}
|
||||
except disnake.Forbidden:
|
||||
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):
|
||||
for guild in self.bot.guilds:
|
||||
await self.update_invite_cache(guild)
|
||||
kuby_logger.info("✅ [Invites] Cache des invitations initialisé")
|
||||
|
||||
# Debug: Lister toutes les commandes slash enregistrées
|
||||
slash_cmds = [cmd.name for cmd in self.bot.slash_commands]
|
||||
kuby_logger.info(f"🔍 [Debug] Commandes slash enregistrées : {slash_cmds}")
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_invite_create(self, invite):
|
||||
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):
|
||||
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
|
||||
|
||||
self._init_db()
|
||||
guild = member.guild
|
||||
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
|
||||
for invite in new_invites:
|
||||
if invite.code in old_invites:
|
||||
if invite.uses > old_invites[invite.code]:
|
||||
used_invite = invite
|
||||
break
|
||||
elif invite.uses > 0:
|
||||
# C'est une nouvelle invitation qui a été utilisée
|
||||
used_invite = invite
|
||||
break
|
||||
|
||||
# Mise à jour du cache
|
||||
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:
|
||||
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
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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
|
||||
|
||||
current_present = 0
|
||||
for mid in invited_ids:
|
||||
member = guild.get_member(mid)
|
||||
if member is not None:
|
||||
# "membres présents" doit refléter la présence réelle : on considère présent
|
||||
# uniquement les membres qui sont encore sur le serveur.
|
||||
current_present += 1
|
||||
else:
|
||||
kuby_logger.debug(f"[Invites] Membre invité {mid} non trouvé dans le serveur {guild_id}")
|
||||
|
||||
# Total = tous les invités enregistrés pour cet inviteur.
|
||||
# "présents" = ceux qui sont encore sur le serveur (donc baisse quand quelqu'un quitte).
|
||||
return len(invited_ids), current_present
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"[Invites] Erreur lors de la récupération des statistiques d'invitations pour {user_id}: {e}")
|
||||
return 0, 0
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_remove(self, member):
|
||||
if member.bot: return
|
||||
|
||||
guild = member.guild
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM member_invites WHERE guild_id = ? AND member_id = ?', (guild.id, member.id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
kuby_logger.info(f"🗑️ [Invites] Enregistrement de l'invitation supprimé pour {member.name} (ID: {member.id}) car il a quitté le serveur.")
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"[Invites] Erreur lors de la suppression de l'enregistrement de l'invitation pour {member.name}: {e}")
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_ban(self, guild, user):
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM member_invites WHERE guild_id = ? AND member_id = ?', (guild.id, user.id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
kuby_logger.info(f"🗑️ [Invites] Enregistrement de l'invitation supprimé pour {user.name} (ID: {user.id}) car il a été banni.")
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"[Invites] Erreur lors de la suppression de l'enregistrement de l'invitation pour {user.name}: {e}")
|
||||
|
||||
@commands.slash_command(name="setinvitechannel", description="Définit le salon des logs d'invitations")
|
||||
@check_premium_tier()
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def set_invite_channel(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel):
|
||||
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):
|
||||
user = user or ctx.author
|
||||
total, current = self.get_invite_stats(ctx.guild.id, user.id)
|
||||
await ctx.send(f"📊 **{user.display_name}** : {total} total, {current} présents.")
|
||||
|
||||
def setup(bot):
|
||||
bot.add_cog(Invites(bot))
|
||||
463
commandes/moderation.py
Normal file
463
commandes/moderation.py
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
import disnake
|
||||
from disnake.ext import commands
|
||||
import sqlite3
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
import re
|
||||
from src.logger import kuby_logger
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
class ModReasonModal(disnake.ui.Modal):
|
||||
def __init__(self, cog, action_type, member):
|
||||
self.cog = cog
|
||||
self.action_type = action_type
|
||||
self.member = member
|
||||
|
||||
self.reason_input = disnake.ui.TextInput(
|
||||
label="Raison",
|
||||
placeholder="Entrez la raison ici...",
|
||||
required=True,
|
||||
max_length=500,
|
||||
custom_id="reason"
|
||||
)
|
||||
|
||||
components = [self.reason_input]
|
||||
|
||||
if action_type == "TIMEOUT":
|
||||
self.duration_input = disnake.ui.TextInput(
|
||||
label="Durée (ex: 1h, 30m, 1d)",
|
||||
placeholder="1h",
|
||||
required=True,
|
||||
max_length=10,
|
||||
custom_id="duration"
|
||||
)
|
||||
components.append(self.duration_input)
|
||||
|
||||
super().__init__(title=f"Raison pour {action_type}", components=components)
|
||||
|
||||
async def callback(self, interaction: disnake.ModalInteraction):
|
||||
reason = interaction.text_values["reason"]
|
||||
member = self.member
|
||||
|
||||
if self.action_type == "WARN":
|
||||
await self.cog.warn(interaction, member, reason)
|
||||
elif self.action_type == "TIMEOUT":
|
||||
duration = interaction.text_values["duration"]
|
||||
await self.cog.timeout(interaction, member, duration, reason)
|
||||
elif self.action_type == "KICK":
|
||||
await self.cog.kick(interaction, member, reason)
|
||||
elif self.action_type == "BAN":
|
||||
await self.cog.ban(interaction, member, reason)
|
||||
|
||||
await self.cog.send_modpanel_v2(interaction, member, edit=True)
|
||||
|
||||
class ModConfigModal(disnake.ui.Modal):
|
||||
def __init__(self, field_name, current_value):
|
||||
self.field_name = field_name
|
||||
self.input_field = disnake.ui.TextInput(
|
||||
label=field_name,
|
||||
value=str(current_value),
|
||||
placeholder="Entrez une valeur numérique...",
|
||||
required=True,
|
||||
custom_id="value"
|
||||
)
|
||||
super().__init__(title=f"Modifier {field_name}", components=[self.input_field])
|
||||
|
||||
async def callback(self, interaction: disnake.ModalInteraction):
|
||||
value = interaction.text_values["value"]
|
||||
cog = interaction.bot.get_cog("Moderation")
|
||||
|
||||
mapping = {
|
||||
"Limite Timeout": "warn_limit_timeout",
|
||||
"Limite Kick": "warn_limit_kick",
|
||||
"Limite Ban": "warn_limit_ban",
|
||||
"Durée Timeout (s)": "timeout_duration"
|
||||
}
|
||||
|
||||
if not value.isdigit():
|
||||
return await interaction.response.send_message("❌ Veuillez entrer un nombre valide.", ephemeral=True)
|
||||
|
||||
db_field = mapping.get(self.field_name)
|
||||
conn = sqlite3.connect(cog.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"UPDATE mod_config SET {db_field} = ? WHERE guild_id = ?", (int(value), interaction.guild_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
await interaction.response.send_message(f"✅ {self.field_name} mis à jour.", ephemeral=True)
|
||||
await cog.send_modconfig_v2(interaction, edit=True)
|
||||
|
||||
class Moderation(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.db_path = os.path.join(os.path.dirname(__file__), "..", "config.db")
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''CREATE TABLE IF NOT EXISTS sanctions (id INTEGER PRIMARY KEY AUTOINCREMENT, guild_id INTEGER, user_id INTEGER, moderator_id INTEGER, type TEXT, reason TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, duration INTEGER, status TEXT DEFAULT 'ACTIVE')''')
|
||||
cursor.execute('''CREATE TABLE IF NOT EXISTS mod_config (
|
||||
guild_id INTEGER PRIMARY KEY,
|
||||
log_channel_id INTEGER,
|
||||
board_channel_id INTEGER,
|
||||
appeal_channel_id INTEGER,
|
||||
warn_limit_timeout INTEGER DEFAULT 3,
|
||||
warn_limit_kick INTEGER DEFAULT 5,
|
||||
warn_limit_ban INTEGER DEFAULT 10,
|
||||
timeout_duration INTEGER DEFAULT 3600
|
||||
)''')
|
||||
try: cursor.execute("ALTER TABLE mod_config ADD COLUMN board_channel_id INTEGER")
|
||||
except: pass
|
||||
try: cursor.execute("ALTER TABLE mod_config ADD COLUMN appeal_channel_id INTEGER")
|
||||
except: pass
|
||||
conn.commit(); conn.close()
|
||||
|
||||
def parse_duration(self, duration_str: str) -> int:
|
||||
if not duration_str: return 0
|
||||
units = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400, 'w': 604800}
|
||||
total_seconds = 0
|
||||
matches = re.findall(r'(\d+)([smhdw])', duration_str.lower())
|
||||
for value, unit in matches: total_seconds += int(value) * units[unit]
|
||||
return total_seconds
|
||||
|
||||
def _type_to_emoji(self, s_type: str) -> str:
|
||||
return {"WARN": "⚠️", "TIMEOUT": "⏳", "KICK": "👢", "BAN": "🔨"}.get(s_type, "🛡️")
|
||||
|
||||
def _type_to_action_name(self, s_type: str) -> str:
|
||||
return {"WARN": "Avertissement", "TIMEOUT": "Timeout", "KICK": "Expulsion", "BAN": "Ban"}.get(s_type, "Sanction")
|
||||
|
||||
def _format_duration(self, seconds: int) -> str:
|
||||
if seconds >= 86400:
|
||||
return f"{seconds // 86400} jour(s)"
|
||||
elif seconds >= 3600:
|
||||
return f"{seconds // 3600} heure(s)"
|
||||
elif seconds >= 60:
|
||||
return f"{seconds // 60} minute(s)"
|
||||
return f"{seconds} seconde(s)"
|
||||
|
||||
async def send_sanction_dm(self, user, s_type: str, reason: str, moderator, guild, duration: int = None) -> bool:
|
||||
"""Envoie un DM détaillé à l'utilisateur sanctionné. Retourne True si envoyé."""
|
||||
from commandes.modules_security.rules import load_settings as load_rules_settings
|
||||
|
||||
emoji = self._type_to_emoji(s_type)
|
||||
action_name = self._type_to_action_name(s_type)
|
||||
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('SELECT appeal_channel_id FROM mod_config WHERE guild_id = ?', (guild.id,))
|
||||
res = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
rules_settings = load_rules_settings(guild.id)
|
||||
appeal_channel = guild.get_channel(res[0]) if res and res[0] else None
|
||||
rules_channel = guild.get_channel(rules_settings.get("rules_channel_id")) if rules_settings.get("rules_channel_id") else None
|
||||
|
||||
embed = disnake.Embed(
|
||||
title=f"{emoji} {action_name} sur **{guild.name}**",
|
||||
color=disnake.Color.red() if s_type in ("BAN", "KICK") else disnake.Color.orange()
|
||||
)
|
||||
embed.set_thumbnail(url=guild.icon.url if guild.icon else None)
|
||||
|
||||
embed.add_field(name="📋 Action", value=action_name, inline=True)
|
||||
embed.add_field(name="👤 Modérateur", value=f"**{moderator}**", inline=True)
|
||||
if duration:
|
||||
embed.add_field(name="⏱️ Durée", value=self._format_duration(duration), inline=True)
|
||||
|
||||
embed.add_field(name="📝 Raison", value=f"*{reason if reason else 'Aucune raison spécifiée'}*", inline=False)
|
||||
|
||||
if appeal_channel:
|
||||
embed.add_field(name="🔗 Contester cette sanction", value=f"Vous pouvez contester cette mesure dans {appeal_channel.mention}.", inline=False)
|
||||
|
||||
if rules_channel:
|
||||
embed.add_field(name="📜 Règlement", value=f"[Voir le règlement](https://discord.com/channels/{guild.id}/{rules_channel.id})", inline=False)
|
||||
|
||||
try:
|
||||
await user.send(embed=embed)
|
||||
return True
|
||||
except disnake.Forbidden:
|
||||
kuby_logger.warning(f"Impossible d'envoyer un DM à {user} (DMs bloqués)")
|
||||
return False
|
||||
|
||||
def build_sanction_board_components(self, *, s_type: str, user, moderator, reason, timestamp, s_id, duration=None) -> list:
|
||||
ts_int = int(datetime.fromisoformat(str(timestamp)).timestamp()) if not isinstance(timestamp, datetime) else int(timestamp.timestamp())
|
||||
emoji = self._type_to_emoji(s_type)
|
||||
|
||||
children = [
|
||||
disnake.ui.Section(f"{emoji} **SANCTION : {s_type}**", accessory=disnake.ui.Thumbnail(user.display_avatar.url)),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.TextDisplay(f"- **Membre sanctionné** : {user.mention}"),
|
||||
disnake.ui.TextDisplay(f"- **Modérateur** : {moderator.mention}"),
|
||||
disnake.ui.TextDisplay(f"- **Raison** : “ *{reason if reason else 'Aucune raison spécifiée'}* ”"),
|
||||
disnake.ui.TextDisplay(f"- **Date** : <t:{ts_int}:F>")
|
||||
]
|
||||
|
||||
if s_type == "TIMEOUT" and duration:
|
||||
children.append(disnake.ui.TextDisplay(f"- **Durée** : `{duration}s`"))
|
||||
|
||||
children.extend([
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.TextDisplay(f"🆔 **ID** : `{s_id}`")
|
||||
])
|
||||
|
||||
return [disnake.ui.Container(*children)]
|
||||
|
||||
async def send_mod_log(self, guild, components):
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('SELECT log_channel_id FROM mod_config WHERE guild_id = ?', (guild.id,))
|
||||
res = cursor.fetchone(); conn.close()
|
||||
if res and res[0]:
|
||||
chan = guild.get_channel(res[0])
|
||||
if chan: await chan.send(components=components)
|
||||
|
||||
async def send_sanction_board(self, guild, components):
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('SELECT board_channel_id FROM mod_config WHERE guild_id = ?', (guild.id,))
|
||||
res = cursor.fetchone(); conn.close()
|
||||
if res and res[0]:
|
||||
chan = guild.get_channel(res[0])
|
||||
if chan: await chan.send(components=components)
|
||||
|
||||
async def send_modpanel_v2(self, interaction: disnake.Interaction, member: disnake.Member, edit=False):
|
||||
self._init_db()
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('SELECT COUNT(*) FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "WARN" AND status = "ACTIVE"', (interaction.guild_id, member.id))
|
||||
warn_count = cursor.fetchone()[0]
|
||||
cursor.execute('SELECT type, reason, timestamp FROM sanctions WHERE guild_id = ? AND user_id = ? AND status = "ACTIVE" ORDER BY timestamp DESC LIMIT 3', (interaction.guild_id, member.id))
|
||||
rows = cursor.fetchall(); conn.close()
|
||||
|
||||
children = [
|
||||
disnake.ui.Section(f"🛡️ Panel : {member.name}", accessory=disnake.ui.Thumbnail(member.display_avatar.url)),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.TextDisplay(f"👤 **Membre :** {member.mention} (`{member.id}`)"),
|
||||
disnake.ui.TextDisplay(f"⚠️ **Avertissements actifs :** {warn_count}"),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.Section("⚠️ Infliger un avertissement", accessory=disnake.ui.Button(label="Warn", style=disnake.ButtonStyle.secondary, custom_id=f"modpan_warn:{member.id}")),
|
||||
disnake.ui.Section("⏳ Mettre en sourdine", accessory=disnake.ui.Button(label="Timeout", style=disnake.ButtonStyle.secondary, custom_id=f"modpan_timeout:{member.id}")),
|
||||
disnake.ui.Section("👢 Expulser du serveur", accessory=disnake.ui.Button(label="Kick", style=disnake.ButtonStyle.secondary, custom_id=f"modpan_kick:{member.id}")),
|
||||
disnake.ui.Section("🔨 Bannir définitivement", accessory=disnake.ui.Button(label="Ban", style=disnake.ButtonStyle.danger, custom_id=f"modpan_ban:{member.id}")),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.Section("🧹 Réinitialiser les warns", accessory=disnake.ui.Button(label="Clear", style=disnake.ButtonStyle.danger, custom_id=f"modpan_clear:{member.id}"))
|
||||
]
|
||||
|
||||
if rows:
|
||||
children.append(disnake.ui.Separator(divider=True))
|
||||
children.append(disnake.ui.TextDisplay("📌 **Dernières sanctions :**"))
|
||||
for t, r, ts in rows:
|
||||
ts_int = int(datetime.fromisoformat(str(ts)).timestamp()) if not isinstance(ts, datetime) else int(ts.timestamp())
|
||||
children.append(disnake.ui.TextDisplay(f"• {self._type_to_emoji(t)} **{t}** — {r} (<t:{ts_int}:R>)"))
|
||||
|
||||
components = [disnake.ui.Container(*children)]
|
||||
|
||||
if edit:
|
||||
if not interaction.response.is_done(): await interaction.response.edit_message(content=None, components=components)
|
||||
else: await interaction.edit_original_response(content=None, components=components)
|
||||
else:
|
||||
await interaction.response.send_message(components=components, ephemeral=True)
|
||||
|
||||
@commands.slash_command(name="modpanel", description="Ouvre le panel de modération")
|
||||
@check_premium_tier()
|
||||
@commands.has_permissions(moderate_members=True)
|
||||
async def modpanel(self, interaction: disnake.ApplicationCommandInteraction, member: disnake.Member):
|
||||
if member.top_role >= interaction.user.top_role and interaction.user.id != interaction.guild.owner_id:
|
||||
return await interaction.response.send_message("❌ Permissions insuffisantes.", ephemeral=True)
|
||||
await self.send_modpanel_v2(interaction, member)
|
||||
|
||||
async def send_modconfig_v2(self, interaction: disnake.Interaction, edit=False):
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('SELECT log_channel_id, board_channel_id, appeal_channel_id, warn_limit_timeout, warn_limit_kick, warn_limit_ban, timeout_duration FROM mod_config WHERE guild_id = ?', (interaction.guild_id,))
|
||||
res = cursor.fetchone(); conn.close()
|
||||
if not res: return
|
||||
|
||||
children = [
|
||||
disnake.ui.Section("⚙️ Configuration Modération", accessory=disnake.ui.Thumbnail(interaction.guild.icon.url if interaction.guild.icon else None)),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.TextDisplay(f"📁 **Logs internes :** <#{res[0]}>" if res[0] else "📁 **Logs :** Non défini"),
|
||||
disnake.ui.TextDisplay(f"📢 **Affichage public :** <#{res[1]}>" if res[1] else "📢 **Affichage :** Non défini"),
|
||||
disnake.ui.TextDisplay(f"🔗 **Contestation :** <#{res[2]}>" if res[2] else "🔗 **Contestation :** Non défini"),
|
||||
disnake.ui.TextDisplay(f"⚖️ **Paliers :** T:{res[3]} | K:{res[4]} | B:{res[5]}"),
|
||||
disnake.ui.TextDisplay(f"⏱️ **Timeout auto :** {res[6]}s"),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.Section("📁 Salon des logs", accessory=disnake.ui.Button(label="Logs", style=disnake.ButtonStyle.primary, custom_id="modcfg_logs")),
|
||||
disnake.ui.Section("📢 Salon d'affichage", accessory=disnake.ui.Button(label="Public", style=disnake.ButtonStyle.primary, custom_id="modcfg_board")),
|
||||
disnake.ui.Section("🔗 Salon de contestation", accessory=disnake.ui.Button(label="Contester", style=disnake.ButtonStyle.primary, custom_id="modcfg_appeal")),
|
||||
disnake.ui.Section("⚖️ Modifier paliers", accessory=disnake.ui.Button(label="Paliers", style=disnake.ButtonStyle.secondary, custom_id="modcfg_limits")),
|
||||
disnake.ui.Section("⏱️ Durée timeout", accessory=disnake.ui.Button(label="Durée", style=disnake.ButtonStyle.secondary, custom_id="modcfg_duration"))
|
||||
]
|
||||
|
||||
components = [disnake.ui.Container(*children)]
|
||||
|
||||
if edit:
|
||||
if not interaction.response.is_done(): await interaction.response.edit_message(content=None, components=components)
|
||||
else: await interaction.edit_original_response(content=None, components=components)
|
||||
else:
|
||||
await interaction.response.send_message(components=components, ephemeral=True)
|
||||
|
||||
@commands.slash_command(name="sanctions", description="Voir l'historique des sanctions d'un membre")
|
||||
@check_premium_tier()
|
||||
@commands.has_permissions(moderate_members=True)
|
||||
async def sanctions(self, interaction: disnake.ApplicationCommandInteraction, member: disnake.Member):
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('SELECT type, reason, timestamp, moderator_id, duration FROM sanctions WHERE guild_id = ? AND user_id = ? ORDER BY timestamp DESC LIMIT 10', (interaction.guild_id, member.id))
|
||||
rows = cursor.fetchall(); conn.close()
|
||||
|
||||
if not rows:
|
||||
return await interaction.response.send_message(f"Aucune sanction trouvée pour {member.mention}.", ephemeral=True)
|
||||
|
||||
children = [
|
||||
disnake.ui.Section(f"📋 Historique : {member.name}", accessory=disnake.ui.Thumbnail(member.display_avatar.url)),
|
||||
disnake.ui.Separator(divider=True),
|
||||
]
|
||||
|
||||
for t, r, ts, mod_id, dur in rows:
|
||||
ts_int = int(datetime.fromisoformat(str(ts)).timestamp()) if not isinstance(ts, datetime) else int(ts.timestamp())
|
||||
moderator = interaction.guild.get_member(mod_id)
|
||||
mod_name = moderator.name if moderator else f"Modérateur #{mod_id}"
|
||||
dur_str = f" ({self._format_duration(dur)})" if dur else ""
|
||||
children.append(disnake.ui.TextDisplay(f"{self._type_to_emoji(t)} **{t}{dur_str}** par **{mod_name}** — {r or 'Sans raison'} (<t:{ts_int}:R>)"))
|
||||
|
||||
components = [disnake.ui.Container(*children)]
|
||||
await interaction.response.send_message(components=components, ephemeral=True)
|
||||
|
||||
@commands.slash_command(name="modconfig", description="Configuration de la modération")
|
||||
@check_premium_tier()
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def modconfig(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('INSERT OR IGNORE INTO mod_config (guild_id) VALUES (?)', (interaction.guild_id,))
|
||||
conn.commit(); conn.close()
|
||||
await self.send_modconfig_v2(interaction)
|
||||
|
||||
@commands.Cog.listener("on_message_interaction")
|
||||
async def on_mod_interaction(self, interaction: disnake.MessageInteraction):
|
||||
cid = interaction.data.custom_id
|
||||
if not cid: return
|
||||
|
||||
if cid.startswith("modpan_"):
|
||||
action, mid = cid.replace("modpan_", "").split(":")
|
||||
member = interaction.guild.get_member(int(mid))
|
||||
if not member: return await interaction.response.send_message("❌ Membre introuvable.", ephemeral=True)
|
||||
|
||||
# Vérifications des permissions et de la hiérarchie
|
||||
if member.top_role >= interaction.user.top_role and interaction.user.id != interaction.guild.owner_id:
|
||||
return await interaction.response.send_message("❌ Permissions insuffisantes : ce membre a un rôle supérieur ou égal au vôtre.", ephemeral=True)
|
||||
|
||||
perms = interaction.user.guild_permissions
|
||||
if action == "ban" and not perms.ban_members:
|
||||
return await interaction.response.send_message("❌ Vous n'avez pas la permission de bannir des membres.", ephemeral=True)
|
||||
elif action == "kick" and not perms.kick_members:
|
||||
return await interaction.response.send_message("❌ Vous n'avez pas la permission d'expulser des membres.", ephemeral=True)
|
||||
elif action in ["timeout", "warn", "clear"] and not perms.moderate_members:
|
||||
return await interaction.response.send_message("❌ Vous n'avez pas la permission de gérer les sanctions (moderate_members).", ephemeral=True)
|
||||
|
||||
if action == "clear":
|
||||
await self.clearwarns(interaction, member, "Nettoyage Panel")
|
||||
await self.send_modpanel_v2(interaction, member, edit=True)
|
||||
else:
|
||||
await interaction.response.send_modal(ModReasonModal(self, action.upper(), member))
|
||||
elif cid.startswith("modcfg_"):
|
||||
if not interaction.user.guild_permissions.administrator:
|
||||
return await interaction.response.send_message("❌ Vous devez être administrateur pour modifier cette configuration.", ephemeral=True)
|
||||
|
||||
act = cid.replace("modcfg_", "")
|
||||
if act in ["logs", "board"]:
|
||||
col = "log_channel_id" if act == "logs" else "board_channel_id"
|
||||
view = disnake.ui.View()
|
||||
sel = disnake.ui.ChannelSelect(placeholder="Choisir salon...", channel_types=[disnake.ChannelType.text])
|
||||
async def sel_cb(i):
|
||||
c = sel.values[0]
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute(f"UPDATE mod_config SET {col} = ? WHERE guild_id = ?", (c.id, i.guild_id))
|
||||
conn.commit(); conn.close()
|
||||
await i.response.send_message(f"✅ Salon mis à jour.", ephemeral=True)
|
||||
await self.send_modconfig_v2(interaction, edit=True)
|
||||
sel.callback = sel_cb; view.add_item(sel)
|
||||
await interaction.response.send_message(f"Sélectionnez le salon pour {act} :", view=view, ephemeral=True)
|
||||
elif act == "limits":
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute("SELECT warn_limit_timeout, warn_limit_kick, warn_limit_ban FROM mod_config WHERE guild_id = ?", (interaction.guild_id,))
|
||||
res = cursor.fetchone(); conn.close()
|
||||
view = disnake.ui.View()
|
||||
b1 = disnake.ui.Button(label=f"Timeout ({res[0]})"); b1.callback = lambda i: i.response.send_modal(ModConfigModal("Limite Timeout", res[0]))
|
||||
b2 = disnake.ui.Button(label=f"Kick ({res[1]})"); b2.callback = lambda i: i.response.send_modal(ModConfigModal("Limite Kick", res[1]))
|
||||
b3 = disnake.ui.Button(label=f"Ban ({res[2]})"); b3.callback = lambda i: i.response.send_modal(ModConfigModal("Limite Ban", res[2]))
|
||||
view.add_item(b1); view.add_item(b2); view.add_item(b3)
|
||||
await interaction.response.send_message("Modifier quel palier ?", view=view, ephemeral=True)
|
||||
elif act == "duration":
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute("SELECT timeout_duration FROM mod_config WHERE guild_id = ?", (interaction.guild_id,))
|
||||
res = cursor.fetchone(); conn.close()
|
||||
await interaction.response.send_modal(ModConfigModal("Durée Timeout (s)", res[0]))
|
||||
elif act == "appeal":
|
||||
view = disnake.ui.View()
|
||||
sel = disnake.ui.ChannelSelect(placeholder="Choisir salon...", channel_types=[disnake.ChannelType.text])
|
||||
async def sel_cb(i):
|
||||
c = sel.values[0]
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute("UPDATE mod_config SET appeal_channel_id = ? WHERE guild_id = ?", (c.id, i.guild_id))
|
||||
conn.commit(); conn.close()
|
||||
await i.response.send_message(f"✅ Salon de contestation mis à jour.", ephemeral=True)
|
||||
await self.send_modconfig_v2(interaction, edit=True)
|
||||
sel.callback = sel_cb; view.add_item(sel)
|
||||
await interaction.response.send_message("Sélectionnez le salon pour la contestation :", view=view, ephemeral=True)
|
||||
|
||||
async def warn(self, interaction, member, reason):
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('INSERT INTO sanctions (guild_id, user_id, moderator_id, type, reason) VALUES (?, ?, ?, ?, ?)', (interaction.guild_id, member.id, interaction.user.id, 'WARN', reason))
|
||||
cursor.execute('SELECT COUNT(*) FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "WARN" AND status = "ACTIVE"', (interaction.guild_id, member.id))
|
||||
count = cursor.fetchone()[0]
|
||||
cursor.execute('SELECT warn_limit_timeout, warn_limit_kick, warn_limit_ban, timeout_duration FROM mod_config WHERE guild_id = ?', (interaction.guild_id,))
|
||||
cfg = cursor.fetchone()
|
||||
cursor.execute('SELECT id, timestamp FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "WARN" ORDER BY id DESC LIMIT 1', (interaction.guild_id, member.id))
|
||||
s_id, ts = cursor.fetchone(); conn.commit(); conn.close()
|
||||
|
||||
if not interaction.response.is_done(): await interaction.response.send_message(f"✅ {member.mention} averti ({count}).", ephemeral=True)
|
||||
await self.send_mod_log(interaction.guild, [disnake.ui.Container(disnake.ui.Section(f"⚠️ Warn : {member}", accessory=disnake.ui.Thumbnail(member.display_avatar.url)), disnake.ui.Separator(divider=True), disnake.ui.TextDisplay(f"Raison: {reason}"), disnake.ui.TextDisplay(f"Total: {count}"))])
|
||||
await self.send_sanction_board(interaction.guild, self.build_sanction_board_components(s_type="WARN", user=member, moderator=interaction.user, reason=reason, timestamp=ts, s_id=s_id))
|
||||
dm_sent = await self.send_sanction_dm(member, "WARN", reason, interaction.user, interaction.guild)
|
||||
if not dm_sent:
|
||||
kuby_logger.warning(f"DM non envoyé à {member} (DMs peut-être bloqués)")
|
||||
if cfg:
|
||||
lt, lk, lb, dur = cfg
|
||||
if count >= lb: await member.ban(reason=f"Auto: {count} warns")
|
||||
elif count >= lk: await member.kick(reason=f"Auto: {count} warns")
|
||||
elif count >= lt: await member.timeout(duration=timedelta(seconds=dur), reason=f"Auto: {count} warns")
|
||||
|
||||
async def timeout(self, interaction, member, duration_str, reason):
|
||||
secs = self.parse_duration(duration_str)
|
||||
if secs <= 0: return await interaction.response.send_message("❌ Durée invalide.", ephemeral=True)
|
||||
await member.timeout(duration=timedelta(seconds=secs), reason=reason)
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('INSERT INTO sanctions (guild_id, user_id, moderator_id, type, reason, duration) VALUES (?, ?, ?, ?, ?, ?)', (interaction.guild_id, member.id, interaction.user.id, 'TIMEOUT', reason, secs))
|
||||
cursor.execute('SELECT id, timestamp FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "TIMEOUT" ORDER BY id DESC LIMIT 1', (interaction.guild_id, member.id))
|
||||
s_id, ts = cursor.fetchone(); conn.commit(); conn.close()
|
||||
if not interaction.response.is_done(): await interaction.response.send_message(f"✅ {member.mention} timeout.", ephemeral=True)
|
||||
await self.send_sanction_board(interaction.guild, self.build_sanction_board_components(s_type="TIMEOUT", user=member, moderator=interaction.user, reason=reason, timestamp=ts, s_id=s_id, duration=secs))
|
||||
await self.send_sanction_dm(member, "TIMEOUT", reason, interaction.user, interaction.guild, duration=secs)
|
||||
|
||||
async def kick(self, interaction, member, reason):
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('INSERT INTO sanctions (guild_id, user_id, moderator_id, type, reason) VALUES (?, ?, ?, ?, ?)', (interaction.guild_id, member.id, interaction.user.id, 'KICK', reason))
|
||||
cursor.execute('SELECT id, timestamp FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "KICK" ORDER BY id DESC LIMIT 1', (interaction.guild_id, member.id))
|
||||
s_id, ts = cursor.fetchone(); conn.commit(); conn.close()
|
||||
await self.send_sanction_dm(member, "KICK", reason, interaction.user, interaction.guild)
|
||||
await member.kick(reason=reason)
|
||||
if not interaction.response.is_done(): await interaction.response.send_message(f"✅ {member.name} expulsé.", ephemeral=True)
|
||||
await self.send_sanction_board(interaction.guild, self.build_sanction_board_components(s_type="KICK", user=member, moderator=interaction.user, reason=reason, timestamp=ts, s_id=s_id))
|
||||
|
||||
async def ban(self, interaction, user, reason):
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('INSERT INTO sanctions (guild_id, user_id, moderator_id, type, reason) VALUES (?, ?, ?, ?, ?)', (interaction.guild_id, user.id, interaction.user.id, 'BAN', reason))
|
||||
cursor.execute('SELECT id, timestamp FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "BAN" ORDER BY id DESC LIMIT 1', (interaction.guild_id, user.id))
|
||||
s_id, ts = cursor.fetchone(); conn.commit(); conn.close()
|
||||
await self.send_sanction_dm(user, "BAN", reason, interaction.user, interaction.guild)
|
||||
await interaction.guild.ban(user, reason=reason)
|
||||
if not interaction.response.is_done(): await interaction.response.send_message(f"✅ {user.name} banni.", ephemeral=True)
|
||||
await self.send_sanction_board(interaction.guild, self.build_sanction_board_components(s_type="BAN", user=user, moderator=interaction.user, reason=reason, timestamp=ts, s_id=s_id))
|
||||
|
||||
async def clearwarns(self, interaction, member, reason):
|
||||
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
|
||||
cursor.execute('UPDATE sanctions SET status = "REVOKED" WHERE guild_id = ? AND user_id = ? AND type = "WARN"', (interaction.guild_id, member.id))
|
||||
conn.commit(); conn.close()
|
||||
if not interaction.response.is_done(): await interaction.response.send_message(f"✅ Warns effacés.", ephemeral=True)
|
||||
|
||||
def setup(bot): bot.add_cog(Moderation(bot))
|
||||
|
|
@ -1,11 +1,17 @@
|
|||
import discord
|
||||
from discord.ext import commands
|
||||
import disnake
|
||||
from disnake.ext import commands
|
||||
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
|
||||
|
|
@ -53,8 +59,8 @@ class AntiRaid(commands.Cog):
|
|||
if log_channel_id:
|
||||
channel = guild.get_channel(int(log_channel_id))
|
||||
if channel:
|
||||
embed = discord.Embed(title="🚨 ALERTE RAID", description=f"Vague de {len(self.join_cache[guild.id])} membres !", color=0xFF0000)
|
||||
embed = disnake.Embed(title="🚨 ALERTE RAID", description=f"Vague de {len(self.join_cache[guild.id])} membres !", color=0xFF0000)
|
||||
await channel.send(embed=embed)
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(AntiRaid(bot))
|
||||
def setup(bot):
|
||||
bot.add_cog(AntiRaid(bot))
|
||||
|
|
@ -1,19 +1,24 @@
|
|||
import discord
|
||||
from discord.ext import commands
|
||||
import disnake
|
||||
from disnake.ext import commands
|
||||
import json
|
||||
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
|
||||
|
||||
# Triggers pour détection de liens
|
||||
LINK_TRIGGERS = ("discord.gg/", "http://", "https://", "www.")
|
||||
LINK_TRIGGERS = ("disnake.gg/", "http://", "https://", "www.")
|
||||
|
||||
class AntiSpam(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
|
|
@ -28,7 +33,7 @@ class AntiSpam(commands.Cog):
|
|||
|
||||
def _get_cached_settings(self, guild_id):
|
||||
"""Récupère les paramètres avec cache (TTL de 5 secondes)"""
|
||||
now = discord.utils.utcnow().timestamp()
|
||||
now = disnake.utils.utcnow().timestamp()
|
||||
cached = self._settings_cache.get(guild_id)
|
||||
|
||||
if cached:
|
||||
|
|
@ -52,7 +57,7 @@ class AntiSpam(commands.Cog):
|
|||
|
||||
def _get_cached_whitelist(self, guild_id):
|
||||
"""Récupère la whitelist avec cache (TTL de 5 secondes)"""
|
||||
now = discord.utils.utcnow().timestamp()
|
||||
now = disnake.utils.utcnow().timestamp()
|
||||
cached = self._whitelist_cache.get(guild_id)
|
||||
|
||||
if cached:
|
||||
|
|
@ -117,7 +122,7 @@ class AntiSpam(commands.Cog):
|
|||
|
||||
# --- TEST ANTI-SPAM (deque pour O(1)) ---
|
||||
if settings.get("anti_spam") is True:
|
||||
now = discord.utils.utcnow().timestamp()
|
||||
now = disnake.utils.utcnow().timestamp()
|
||||
|
||||
# Initialiser ou récupérer le deque pour cet utilisateur
|
||||
if user_id not in self.message_counts:
|
||||
|
|
@ -156,7 +161,7 @@ class AntiSpam(commands.Cog):
|
|||
self.message_counts[user_id] = deque(maxlen=10)
|
||||
|
||||
if current_warns <= 3:
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title="🛡️ Sécurité Kuby",
|
||||
description=f"{message.author.mention}, {reason}.\n**Avertissement : {current_warns}/3**",
|
||||
color=0xFFA500
|
||||
|
|
@ -169,10 +174,10 @@ class AntiSpam(commands.Cog):
|
|||
|
||||
async def apply_mute(self, message, reason):
|
||||
try:
|
||||
until = discord.utils.utcnow() + timedelta(minutes=5)
|
||||
until = disnake.utils.utcnow() + timedelta(minutes=5)
|
||||
await message.author.timeout(until, reason=f"Kuby Security: {reason} (3 Warns)")
|
||||
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title="⛔ Exclusion Temporaire",
|
||||
description=f"**Membre :** {message.author.mention}\n**Durée :** 5 minutes\n**Raison :** {reason} répétitif.",
|
||||
color=0xFF0000
|
||||
|
|
@ -181,5 +186,5 @@ class AntiSpam(commands.Cog):
|
|||
except Exception as e:
|
||||
print(f"Erreur lors de la sanction : {e}")
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(AntiSpam(bot))
|
||||
def setup(bot):
|
||||
bot.add_cog(AntiSpam(bot))
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
import discord
|
||||
from discord.ext import commands
|
||||
import disnake
|
||||
from disnake.ext import commands
|
||||
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")
|
||||
|
|
@ -12,6 +18,15 @@ def get_log_channel(guild, settings):
|
|||
return guild.get_channel(int(channel_id))
|
||||
return None
|
||||
|
||||
def load_settings():
|
||||
if not os.path.exists(SETTINGS_FILE):
|
||||
return {}
|
||||
try:
|
||||
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
class LogsManager(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
|
@ -19,44 +34,44 @@ class LogsManager(commands.Cog):
|
|||
@commands.Cog.listener()
|
||||
async def on_message_delete(self, message):
|
||||
if message.author.bot: return
|
||||
settings = dict(json.load(open(SETTINGS_FILE, "r"))).get(str(message.guild.id), {})
|
||||
settings = dict(load_settings()).get(str(message.guild.id), {})
|
||||
|
||||
log_chan = get_log_channel(message.guild, settings)
|
||||
if log_chan:
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title="🗑️ Message Supprimé",
|
||||
description=f"**Auteur :** {message.author.mention}\n**Salon :** {message.channel.mention}",
|
||||
color=0xFFA500,
|
||||
timestamp=datetime.utcnow()
|
||||
timestamp=disnake.utils.utcnow()
|
||||
)
|
||||
embed.add_field(name="Contenu", value=message.content or "*(Image ou Embed)*")
|
||||
await log_chan.send(embed=embed)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_remove(self, member):
|
||||
settings = dict(json.load(open(SETTINGS_FILE, "r"))).get(str(member.guild.id), {})
|
||||
settings = dict(load_settings()).get(str(member.guild.id), {})
|
||||
log_chan = get_log_channel(member.guild, settings)
|
||||
if log_chan:
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title="📤 Départ d'un membre",
|
||||
description=f"{member.mention} ({member.id}) a quitté le serveur.",
|
||||
color=0xFF0000,
|
||||
timestamp=datetime.utcnow()
|
||||
timestamp=disnake.utils.utcnow()
|
||||
)
|
||||
await log_chan.send(embed=embed)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_ban(self, guild, user):
|
||||
settings = dict(json.load(open(SETTINGS_FILE, "r"))).get(str(guild.id), {})
|
||||
settings = dict(load_settings()).get(str(guild.id), {})
|
||||
log_chan = get_log_channel(guild, settings)
|
||||
if log_chan:
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title="🔨 Bannissement",
|
||||
description=f"**Utilisateur :** {user.mention} ({user.id}) a été banni.",
|
||||
color=0x000000,
|
||||
timestamp=datetime.utcnow()
|
||||
timestamp=disnake.utils.utcnow()
|
||||
)
|
||||
await log_chan.send(embed=embed)
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(LogsManager(bot))
|
||||
def setup(bot):
|
||||
bot.add_cog(LogsManager(bot))
|
||||
|
|
@ -1,12 +1,23 @@
|
|||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
import disnake
|
||||
from disnake.ext import commands
|
||||
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"""
|
||||
if not text:
|
||||
return text
|
||||
# Utilise un zero-width space pour briser la mention Discord
|
||||
return text.replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere")
|
||||
|
||||
|
||||
def load_settings(guild_id: int) -> dict:
|
||||
|
|
@ -39,9 +50,9 @@ def save_settings(guild_id: int, settings: dict) -> None:
|
|||
|
||||
# --- MODALS DE CONFIGURATION DU RÈGLEMENT (BACK-END) ---
|
||||
|
||||
class RulesTitleModal(discord.ui.Modal, title="📜 Titre du Règlement"):
|
||||
class RulesTitleModal(disnake.ui.Modal):
|
||||
"""Modal pour configurer le titre du règlement"""
|
||||
title_input = discord.ui.TextInput(
|
||||
title_input = disnake.ui.TextInput(
|
||||
label="Titre du règlement",
|
||||
placeholder="Ex: 📜 Règlement de [Nom du Serveur]",
|
||||
min_length=1,
|
||||
|
|
@ -50,12 +61,12 @@ class RulesTitleModal(discord.ui.Modal, title="📜 Titre du Règlement"):
|
|||
)
|
||||
|
||||
def __init__(self, guild_id: int, settings: dict, on_update_callback=None):
|
||||
super().__init__()
|
||||
super().__init__(title="📜 Titre du Règlement", components=[self.title_input])
|
||||
self.guild_id = guild_id
|
||||
self.settings = settings
|
||||
self.on_update_callback = on_update_callback
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
async def callback(self, interaction: disnake.Interaction):
|
||||
self.settings["rules_title"] = self.title_input.value
|
||||
save_settings(self.guild_id, self.settings)
|
||||
|
||||
|
|
@ -78,25 +89,25 @@ class RulesTitleModal(discord.ui.Modal, title="📜 Titre du Règlement"):
|
|||
return view
|
||||
|
||||
|
||||
class RulesContentModal(discord.ui.Modal, title="📝 Contenu du Règlement"):
|
||||
class RulesContentModal(disnake.ui.Modal):
|
||||
"""Modal pour configurer le contenu du règlement"""
|
||||
content_input = discord.ui.TextInput(
|
||||
content_input = disnake.ui.TextInput(
|
||||
label="Contenu du règlement",
|
||||
placeholder="Entrez les règles de votre serveur...",
|
||||
style=discord.TextStyle.paragraph,
|
||||
style=disnake.TextInputStyle.paragraph,
|
||||
min_length=10,
|
||||
max_length=4000,
|
||||
required=True
|
||||
)
|
||||
|
||||
def __init__(self, guild_id: int, settings: dict, on_update_callback=None):
|
||||
super().__init__()
|
||||
super().__init__(title="📝 Contenu du Règlement", components=[self.content_input])
|
||||
self.guild_id = guild_id
|
||||
self.settings = settings
|
||||
self.on_update_callback = on_update_callback
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
self.settings["rules_content"] = self.content_input.value
|
||||
async def callback(self, interaction: disnake.Interaction):
|
||||
self.settings["rules_content"] = escape_mentions(self.content_input.value)
|
||||
save_settings(self.guild_id, self.settings)
|
||||
|
||||
if self.on_update_callback:
|
||||
|
|
@ -118,9 +129,9 @@ class RulesContentModal(discord.ui.Modal, title="📝 Contenu du Règlement"):
|
|||
return view
|
||||
|
||||
|
||||
class RulesChannelModal(discord.ui.Modal, title="📌 Salon du Règlement"):
|
||||
class RulesChannelModal(disnake.ui.Modal):
|
||||
"""Modal pour sélectionner le salon du règlement"""
|
||||
channel_input = discord.ui.TextInput(
|
||||
channel_input = disnake.ui.TextInput(
|
||||
label="ID du salon",
|
||||
placeholder="Entrez l'ID du salon où envoyer le règlement",
|
||||
min_length=15,
|
||||
|
|
@ -129,12 +140,12 @@ class RulesChannelModal(discord.ui.Modal, title="📌 Salon du Règlement"):
|
|||
)
|
||||
|
||||
def __init__(self, guild_id: int, settings: dict, on_update_callback=None):
|
||||
super().__init__()
|
||||
super().__init__(title="📌 Salon du Règlement", components=[self.channel_input])
|
||||
self.guild_id = guild_id
|
||||
self.settings = settings
|
||||
self.on_update_callback = on_update_callback
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
async def callback(self, interaction: disnake.Interaction):
|
||||
if not self.channel_input.value.isdigit():
|
||||
return await interaction.response.send_message(
|
||||
"❌ Erreur : ID invalide.",
|
||||
|
|
@ -172,9 +183,9 @@ class RulesChannelModal(discord.ui.Modal, title="📌 Salon du Règlement"):
|
|||
return view
|
||||
|
||||
|
||||
class RulesRoleModal(discord.ui.Modal, title="✅ Rôle d'Acceptation"):
|
||||
class RulesRoleModal(disnake.ui.Modal):
|
||||
"""Modal pour configurer le rôle attribué à l'acceptation"""
|
||||
role_input = discord.ui.TextInput(
|
||||
role_input = disnake.ui.TextInput(
|
||||
label="ID du rôle",
|
||||
placeholder="Entrez l'ID du rôle à attribuer",
|
||||
min_length=15,
|
||||
|
|
@ -183,12 +194,12 @@ class RulesRoleModal(discord.ui.Modal, title="✅ Rôle d'Acceptation"):
|
|||
)
|
||||
|
||||
def __init__(self, guild_id: int, settings: dict, on_update_callback=None):
|
||||
super().__init__()
|
||||
super().__init__(title="✅ Rôle d'Acceptation", components=[self.role_input])
|
||||
self.guild_id = guild_id
|
||||
self.settings = settings
|
||||
self.on_update_callback = on_update_callback
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
async def callback(self, interaction: disnake.Interaction):
|
||||
if not self.role_input.value.isdigit():
|
||||
return await interaction.response.send_message(
|
||||
"❌ Erreur : ID invalide.",
|
||||
|
|
@ -226,9 +237,9 @@ class RulesRoleModal(discord.ui.Modal, title="✅ Rôle d'Acceptation"):
|
|||
return view
|
||||
|
||||
|
||||
class RulesMessageTypeModal(discord.ui.Modal, title="📝 Type de Message"):
|
||||
class RulesMessageTypeModal(disnake.ui.Modal):
|
||||
"""Modal pour choisir le type de message du règlement"""
|
||||
message_type = discord.ui.TextInput(
|
||||
message_type = disnake.ui.TextInput(
|
||||
label="Type de message",
|
||||
placeholder="Tapez 'embed' pour embed ou 'simple' pour message simple",
|
||||
min_length=5,
|
||||
|
|
@ -237,12 +248,12 @@ class RulesMessageTypeModal(discord.ui.Modal, title="📝 Type de Message"):
|
|||
)
|
||||
|
||||
def __init__(self, guild_id: int, settings: dict, on_update_callback=None):
|
||||
super().__init__()
|
||||
super().__init__(title="📝 Type de Message", components=[self.message_type])
|
||||
self.guild_id = guild_id
|
||||
self.settings = settings
|
||||
self.on_update_callback = on_update_callback
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
async def callback(self, interaction: disnake.Interaction):
|
||||
msg_type = self.message_type.value.lower().strip()
|
||||
|
||||
if msg_type not in ["embed", "simple"]:
|
||||
|
|
@ -274,11 +285,11 @@ class RulesMessageTypeModal(discord.ui.Modal, title="📝 Type de Message"):
|
|||
return view
|
||||
|
||||
|
||||
class ToggleRulesButton(discord.ui.Button):
|
||||
class ToggleRulesButton(disnake.ui.Button):
|
||||
"""Bouton de bascule pour activer/désactiver le règlement"""
|
||||
|
||||
def __init__(self, is_enabled: bool):
|
||||
style = discord.ButtonStyle.green if is_enabled else discord.ButtonStyle.red
|
||||
style = disnake.ButtonStyle.green if is_enabled else disnake.ButtonStyle.red
|
||||
status = "✅" if is_enabled else "❌"
|
||||
super().__init__(
|
||||
label=f"Règlement [{status}]",
|
||||
|
|
@ -287,7 +298,7 @@ class ToggleRulesButton(discord.ui.Button):
|
|||
emoji="📜"
|
||||
)
|
||||
|
||||
async def callback(self, interaction: discord.Interaction):
|
||||
async def callback(self, interaction: disnake.Interaction):
|
||||
view = self.view
|
||||
settings = view.settings
|
||||
guild = interaction.guild
|
||||
|
|
@ -316,7 +327,7 @@ class ToggleRulesButton(discord.ui.Button):
|
|||
await interaction.response.edit_message(embed=embed, view=new_view)
|
||||
|
||||
|
||||
class RulesAcceptButton(discord.ui.View):
|
||||
class RulesAcceptButton(disnake.ui.View):
|
||||
"""Bouton persistant pour accepter le règlement"""
|
||||
|
||||
def __init__(self, guild_id: int, accept_role_id: int):
|
||||
|
|
@ -324,13 +335,13 @@ class RulesAcceptButton(discord.ui.View):
|
|||
self.guild_id = guild_id
|
||||
self.accept_role_id = accept_role_id
|
||||
|
||||
@discord.ui.button(
|
||||
@disnake.ui.button(
|
||||
label="J'accepte le règlement",
|
||||
emoji="✅",
|
||||
style=discord.ButtonStyle.green,
|
||||
style=disnake.ButtonStyle.green,
|
||||
custom_id="rules_accept_button"
|
||||
)
|
||||
async def accept_rules(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
async def accept_rules(self, button: disnake.ui.Button, interaction: disnake.Interaction):
|
||||
"""Gère le clic sur le bouton d'acceptation"""
|
||||
guild = interaction.guild
|
||||
|
||||
|
|
@ -368,7 +379,7 @@ class RulesAcceptButton(discord.ui.View):
|
|||
f"Le rôle **{role.name}** vous a été attribué.",
|
||||
ephemeral=True
|
||||
)
|
||||
except discord.Forbidden:
|
||||
except disnake.Forbidden:
|
||||
await interaction.response.send_message(
|
||||
"❌ Je n'ai pas les permissions pour attribuer ce rôle.",
|
||||
ephemeral=True
|
||||
|
|
@ -421,7 +432,7 @@ class RulesCog(commands.Cog):
|
|||
"""Appelé quand le bot est prêt"""
|
||||
print("🎫 Module Règles chargé avec succès!")
|
||||
|
||||
async def send_rules_message(self, channel: discord.TextChannel, settings: dict) -> Optional[discord.Message]:
|
||||
async def send_rules_message(self, channel: disnake.TextChannel, settings: dict) -> Optional[disnake.Message]:
|
||||
"""Envoie le message du règlement dans un salon"""
|
||||
title = settings.get("rules_title", "📜 Règlement du Serveur")
|
||||
content = settings.get("rules_content", "")
|
||||
|
|
@ -435,10 +446,11 @@ class RulesCog(commands.Cog):
|
|||
view = RulesAcceptButton(channel.guild.id, accept_role_id)
|
||||
|
||||
if message_type == "simple":
|
||||
# Message simple
|
||||
# Message simple - échappe les mentions @everyone/@here pour éviter les notifications involontaires
|
||||
safe_content = content.replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere")
|
||||
text_message = (
|
||||
f"📜 **{title}**\n\n"
|
||||
f"{content}\n\n"
|
||||
f"{safe_content}\n\n"
|
||||
"Bienvenue ! Pour accéder à l'ensemble des canaux du serveur, "
|
||||
"vous devez accepter le règlement en cliquant sur le bouton ci-dessous.\n\n"
|
||||
"En acceptant, vous confirmez avoir lu et compris les règles du serveur."
|
||||
|
|
@ -456,19 +468,29 @@ class RulesCog(commands.Cog):
|
|||
return None
|
||||
|
||||
else: # embed
|
||||
# Échappe les mentions @everyone/@here pour éviter les notifications involontaires
|
||||
safe_content = content.replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere")
|
||||
|
||||
# Utilise un Container avec TextDisplay pour un meilleur rendu du texte (markdown supporté)
|
||||
components = [
|
||||
disnake.ui.Container(
|
||||
disnake.ui.TextDisplay(safe_content)
|
||||
)
|
||||
]
|
||||
|
||||
# Créer l'embed
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title=title,
|
||||
description=content,
|
||||
color=0x2b2d31
|
||||
)
|
||||
|
||||
embed.set_footer(text="Cliquez sur le bouton ci-dessous pour accepter le règlement")
|
||||
|
||||
# Envoyer seulement l'embed
|
||||
# Envoyer l'embed avec le container et la view
|
||||
try:
|
||||
message = await channel.send(
|
||||
embed=embed,
|
||||
components=components,
|
||||
view=view
|
||||
)
|
||||
return message
|
||||
|
|
@ -496,8 +518,8 @@ class RulesCog(commands.Cog):
|
|||
return title, content
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
def setup(bot):
|
||||
"""Setup du cog de règlement"""
|
||||
cog = RulesCog(bot)
|
||||
await bot.add_cog(cog)
|
||||
bot.add_cog(cog)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
# utils/no_link.py
|
||||
# commandes/no_link.py
|
||||
import re
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
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."""
|
||||
|
|
@ -26,7 +31,7 @@ class NoLink(commands.Cog):
|
|||
self.bot = bot
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message: discord.Message) -> None:
|
||||
async def on_message(self, message: disnake.Message) -> None:
|
||||
if message.author.bot or not message.guild:
|
||||
return
|
||||
|
||||
|
|
@ -41,11 +46,11 @@ class NoLink(commands.Cog):
|
|||
f"{message.author.mention} ⚠️ Les liens sont interdits sur ce serveur.",
|
||||
delete_after=5
|
||||
)
|
||||
except discord.Forbidden:
|
||||
except disnake.Forbidden:
|
||||
print(f"Impossible de supprimer le message de {message.author} (permissions manquantes).")
|
||||
except Exception as exc:
|
||||
print(f"Erreur lors de la suppression du message : {exc}")
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot) -> None:
|
||||
await bot.add_cog(NoLink(bot))
|
||||
def setup(bot: commands.Bot) -> None:
|
||||
bot.add_cog(NoLink(bot))
|
||||
|
|
|
|||
984
commandes/rapports.py
Normal file
984
commandes/rapports.py
Normal file
|
|
@ -0,0 +1,984 @@
|
|||
import os
|
||||
import sqlite3
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
except ImportError:
|
||||
from backports.zoneinfo import ZoneInfo
|
||||
|
||||
import disnake
|
||||
from disnake.ext import commands, tasks
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
kuby_logger = logging.getLogger("KubyBot")
|
||||
PARIS_TZ = ZoneInfo("Europe/Paris")
|
||||
|
||||
class ReportsDB:
|
||||
def __init__(self, db_path):
|
||||
self.db_path = db_path
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Table pour les rapports
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guild_id INTEGER NOT NULL,
|
||||
author_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'En attente',
|
||||
created_at TEXT NOT NULL,
|
||||
last_edited_at TEXT,
|
||||
channel_id INTEGER NOT NULL,
|
||||
role_id_auteur INTEGER
|
||||
)
|
||||
''')
|
||||
|
||||
# Table pour la configuration du module
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS reports_config (
|
||||
guild_id INTEGER PRIMARY KEY,
|
||||
viewer_role_id INTEGER
|
||||
)
|
||||
''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_config(self, guild_id):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT viewer_role_id FROM reports_config WHERE guild_id = ?", (guild_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return row[0] if row else None
|
||||
|
||||
def set_config(self, guild_id, viewer_role_id):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO reports_config (guild_id, viewer_role_id)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(guild_id) DO UPDATE SET viewer_role_id = excluded.viewer_role_id
|
||||
''', (guild_id, viewer_role_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def add_report(self, guild_id, author_id, title, subject, content, channel_id, role_id_auteur):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
created_at = datetime.now(PARIS_TZ).isoformat()
|
||||
cursor.execute('''
|
||||
INSERT INTO reports (guild_id, author_id, title, subject, content, status, created_at, channel_id, role_id_auteur)
|
||||
VALUES (?, ?, ?, ?, ?, 'En attente', ?, ?, ?)
|
||||
''', (guild_id, author_id, title, subject, content, created_at, channel_id, role_id_auteur))
|
||||
report_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return report_id
|
||||
|
||||
def get_report(self, report_id):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM reports WHERE id = ?", (report_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
def update_report_status(self, report_id, status):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
last_edited_at = datetime.now(PARIS_TZ).isoformat()
|
||||
cursor.execute('''
|
||||
UPDATE reports
|
||||
SET status = ?, last_edited_at = ?
|
||||
WHERE id = ?
|
||||
''', (status, last_edited_at, report_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def update_report_content(self, report_id, title, subject, content):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
last_edited_at = datetime.now(PARIS_TZ).isoformat()
|
||||
cursor.execute('''
|
||||
UPDATE reports
|
||||
SET title = ?, subject = ?, content = ?, last_edited_at = ?
|
||||
WHERE id = ?
|
||||
''', (title, subject, content, last_edited_at, report_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def delete_report(self, report_id):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM reports WHERE id = ?", (report_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_reports(self, guild_id, author_id=None, subject=None, status=None, role_id=None):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM reports WHERE guild_id = ?"
|
||||
params = [guild_id]
|
||||
|
||||
if author_id is not None:
|
||||
query += " AND author_id = ?"
|
||||
params.append(author_id)
|
||||
if subject is not None:
|
||||
query += " AND subject LIKE ?"
|
||||
params.append(f"%{subject}%")
|
||||
if status is not None:
|
||||
query += " AND status = ?"
|
||||
params.append(status)
|
||||
if role_id is not None:
|
||||
query += " AND role_id_auteur = ?"
|
||||
params.append(role_id)
|
||||
|
||||
query += " ORDER BY id ASC"
|
||||
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_distinct_subjects(self, guild_id):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT DISTINCT subject FROM reports WHERE guild_id = ? ORDER BY subject ASC", (guild_id,))
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return [row[0] for row in rows if row[0]]
|
||||
|
||||
def purge_manual(self, guild_id):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cutoff = (datetime.now(PARIS_TZ) - timedelta(days=7)).isoformat()
|
||||
cursor.execute('''
|
||||
DELETE FROM reports
|
||||
WHERE guild_id = ? AND status != 'Classé' AND created_at < ?
|
||||
''', (guild_id, cutoff))
|
||||
deleted_count = cursor.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return deleted_count
|
||||
|
||||
def purge_automatic(self):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cutoff = (datetime.now(PARIS_TZ) - timedelta(days=90)).isoformat()
|
||||
cursor.execute('''
|
||||
DELETE FROM reports
|
||||
WHERE status != 'Classé' AND created_at < ?
|
||||
''', (cutoff,))
|
||||
deleted_count = cursor.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return deleted_count
|
||||
|
||||
class ReportsConfigView(disnake.ui.View):
|
||||
def __init__(self, cog, guild_id: int):
|
||||
super().__init__(timeout=300)
|
||||
self.cog = cog
|
||||
self.guild_id = guild_id
|
||||
|
||||
@disnake.ui.role_select(placeholder="Modifier le rôle Lecteur (Viewer)", min_values=1, max_values=1, custom_id="reports_select_viewer_role")
|
||||
async def select_viewer_role(self, select: disnake.ui.RoleSelect, interaction: disnake.MessageInteraction):
|
||||
if not interaction.author.guild_permissions.administrator:
|
||||
return await interaction.response.send_message("❌ Vous devez être administrateur pour configurer ce module.", ephemeral=True)
|
||||
|
||||
role = select.values[0]
|
||||
self.cog.db.set_config(self.guild_id, role.id)
|
||||
|
||||
embed = interaction.message.embeds[0]
|
||||
embed.set_field_at(
|
||||
0,
|
||||
name="👁️ Rôle Lecteur (Viewer)",
|
||||
value=f"{role.mention}\n*(Les membres ayant ce rôle peuvent consulter tous les rapports du serveur)*",
|
||||
inline=False
|
||||
)
|
||||
await interaction.response.edit_message(embed=embed, view=self)
|
||||
await interaction.followup.send(f"✅ Rôle Lecteur configuré sur {role.mention}", ephemeral=True)
|
||||
|
||||
@disnake.ui.button(label="Purge Manuelle", style=disnake.ButtonStyle.red, emoji="🗑️", custom_id="reports_manual_purge_btn")
|
||||
async def manual_purge(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction):
|
||||
if not interaction.author.guild_permissions.administrator:
|
||||
return await interaction.response.send_message("❌ Vous devez être administrateur pour lancer la purge.", ephemeral=True)
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
deleted_count = self.cog.db.purge_manual(self.guild_id)
|
||||
await interaction.followup.send(f"✅ Purge terminée ! **{deleted_count}** rapports (créés il y a > 7 jours et non-classés) ont été définitivement supprimés.", ephemeral=True)
|
||||
|
||||
class ReportModal(disnake.ui.Modal):
|
||||
def __init__(self, cog):
|
||||
self.cog = cog
|
||||
components = [
|
||||
disnake.ui.TextInput(
|
||||
label="Titre du rapport",
|
||||
placeholder="Entrez le titre du rapport...",
|
||||
custom_id="report_title",
|
||||
required=True,
|
||||
max_length=100
|
||||
),
|
||||
disnake.ui.TextInput(
|
||||
label="Objet du rapport",
|
||||
placeholder="Ex: Bug, Plainte, Suggestion...",
|
||||
custom_id="report_subject",
|
||||
required=True,
|
||||
max_length=100
|
||||
),
|
||||
disnake.ui.TextInput(
|
||||
label="Contenu",
|
||||
style=disnake.TextInputStyle.paragraph,
|
||||
placeholder="Décrivez en détail...",
|
||||
custom_id="report_content",
|
||||
required=True,
|
||||
max_length=2000
|
||||
)
|
||||
]
|
||||
super().__init__(title="Créer un Rapport", custom_id="report_modal", components=components)
|
||||
|
||||
async def callback(self, interaction: disnake.ModalInteraction):
|
||||
title = interaction.text_values["report_title"].strip()
|
||||
subject = interaction.text_values["report_subject"].strip()
|
||||
content = interaction.text_values["report_content"].strip()
|
||||
|
||||
role_id_auteur = interaction.author.top_role.id if interaction.author.roles else None
|
||||
|
||||
report_id = self.cog.db.add_report(
|
||||
guild_id=interaction.guild.id,
|
||||
author_id=interaction.author.id,
|
||||
title=title,
|
||||
subject=subject,
|
||||
content=content,
|
||||
channel_id=interaction.channel.id,
|
||||
role_id_auteur=role_id_auteur
|
||||
)
|
||||
|
||||
await interaction.response.send_message(
|
||||
f"✅ Votre rapport a été enregistré avec succès sous l'ID **#{report_id}**.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
class EditReportModal(disnake.ui.Modal):
|
||||
def __init__(self, cog, report_id, browser_view):
|
||||
self.cog = cog
|
||||
self.report_id = report_id
|
||||
self.browser_view = browser_view
|
||||
|
||||
report = self.cog.db.get_report(report_id)
|
||||
|
||||
components = [
|
||||
disnake.ui.TextInput(
|
||||
label="Modifier le titre",
|
||||
value=report['title'],
|
||||
custom_id="edit_title",
|
||||
required=True,
|
||||
max_length=100
|
||||
),
|
||||
disnake.ui.TextInput(
|
||||
label="Modifier l'objet",
|
||||
value=report['subject'],
|
||||
custom_id="edit_subject",
|
||||
required=True,
|
||||
max_length=100
|
||||
),
|
||||
disnake.ui.TextInput(
|
||||
label="Modifier le contenu",
|
||||
style=disnake.TextInputStyle.paragraph,
|
||||
value=report['content'],
|
||||
custom_id="edit_content",
|
||||
required=True,
|
||||
max_length=2000
|
||||
)
|
||||
]
|
||||
super().__init__(title=f"Modifier le Rapport #{report_id}", custom_id=f"edit_modal_{report_id}", components=components)
|
||||
|
||||
async def callback(self, interaction: disnake.ModalInteraction):
|
||||
report = self.cog.db.get_report(self.report_id)
|
||||
if not report:
|
||||
return await interaction.response.send_message("❌ Ce rapport n'existe plus.", ephemeral=True)
|
||||
if report['status'] in ['Traité', 'Classé']:
|
||||
return await interaction.response.send_message("❌ Ce rapport a été traité ou classé et ne peut plus être modifié.", ephemeral=True)
|
||||
|
||||
title = interaction.text_values["edit_title"].strip()
|
||||
subject = interaction.text_values["edit_subject"].strip()
|
||||
content = interaction.text_values["edit_content"].strip()
|
||||
|
||||
self.cog.db.update_report_content(self.report_id, title, subject, content)
|
||||
|
||||
updated_report = self.cog.db.get_report(self.report_id)
|
||||
|
||||
for idx, r in enumerate(self.browser_view.reports):
|
||||
if r['id'] == self.report_id:
|
||||
self.browser_view.reports[idx] = updated_report
|
||||
break
|
||||
|
||||
self.browser_view.update_components()
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
await interaction.message.edit(embed=self.browser_view.get_embed(), view=self.browser_view)
|
||||
await interaction.followup.send("✅ Votre rapport a été modifié avec succès.", ephemeral=True)
|
||||
|
||||
class ReportsFilterView(disnake.ui.View):
|
||||
def __init__(self, cog, interaction):
|
||||
super().__init__(timeout=300)
|
||||
self.cog = cog
|
||||
self.interaction = interaction
|
||||
self.viewer_id = interaction.author.id
|
||||
self.guild_id = interaction.guild.id
|
||||
self.is_admin = interaction.author.guild_permissions.administrator
|
||||
|
||||
viewer_role_id = self.cog.db.get_config(self.guild_id)
|
||||
self.is_viewer = viewer_role_id in [r.id for r in interaction.author.roles] if viewer_role_id else False
|
||||
self.has_global_access = self.is_admin or self.is_viewer
|
||||
|
||||
self.selected_user = None
|
||||
self.selected_role = None
|
||||
self.selected_status = None
|
||||
self.selected_subject = None
|
||||
|
||||
self.update_components()
|
||||
|
||||
def update_components(self):
|
||||
self.clear_items()
|
||||
|
||||
# Row 0: Menu Status
|
||||
status_options = [
|
||||
disnake.SelectOption(label="Tous les statuts", emoji="⚪", value="all", default=(self.selected_status is None)),
|
||||
disnake.SelectOption(label="En attente", emoji="🟡", value="En attente", default=(self.selected_status == "En attente")),
|
||||
disnake.SelectOption(label="Traité", emoji="🟢", value="Traité", default=(self.selected_status == "Traité")),
|
||||
disnake.SelectOption(label="Classé", emoji="🔴", value="Classé", default=(self.selected_status == "Classé"))
|
||||
]
|
||||
status_select = disnake.ui.StringSelect(
|
||||
placeholder="Filtrer par statut...",
|
||||
options=status_options,
|
||||
custom_id="reports_filter_status",
|
||||
row=0
|
||||
)
|
||||
async def status_callback(inter: disnake.MessageInteraction):
|
||||
val = inter.values[0]
|
||||
self.selected_status = None if val == "all" else val
|
||||
self.update_components()
|
||||
await inter.response.edit_message(embed=self.get_filter_embed(), view=self)
|
||||
status_select.callback = status_callback
|
||||
self.add_item(status_select)
|
||||
|
||||
# Row 1: Menu Catégorie
|
||||
subjects = self.cog.db.get_distinct_subjects(self.guild_id)
|
||||
if subjects:
|
||||
subject_options = [
|
||||
disnake.SelectOption(label="Toutes les catégories", emoji="📁", value="all", default=(self.selected_subject is None))
|
||||
]
|
||||
for subj in subjects[:24]:
|
||||
subject_options.append(
|
||||
disnake.SelectOption(label=subj, value=subj, default=(self.selected_subject == subj))
|
||||
)
|
||||
subject_select = disnake.ui.StringSelect(
|
||||
placeholder="Filtrer par catégorie...",
|
||||
options=subject_options,
|
||||
custom_id="reports_filter_subject",
|
||||
row=1
|
||||
)
|
||||
async def subject_callback(inter: disnake.MessageInteraction):
|
||||
val = inter.values[0]
|
||||
self.selected_subject = None if val == "all" else val
|
||||
self.update_components()
|
||||
await inter.response.edit_message(embed=self.get_filter_embed(), view=self)
|
||||
subject_select.callback = subject_callback
|
||||
self.add_item(subject_select)
|
||||
|
||||
current_row = 2
|
||||
|
||||
# Row 2 & 3: Filtres utilisateur / rôle (globaux uniquement)
|
||||
if self.has_global_access:
|
||||
user_select = disnake.ui.UserSelect(
|
||||
placeholder="Filtrer par auteur...",
|
||||
min_values=0,
|
||||
max_values=1,
|
||||
custom_id="reports_filter_user",
|
||||
row=current_row
|
||||
)
|
||||
async def user_callback(inter: disnake.MessageInteraction):
|
||||
if inter.values:
|
||||
val = inter.values[0]
|
||||
if isinstance(val, str):
|
||||
self.selected_user = inter.guild.get_member(int(val)) or val
|
||||
else:
|
||||
self.selected_user = val
|
||||
else:
|
||||
self.selected_user = None
|
||||
self.update_components()
|
||||
await inter.response.edit_message(embed=self.get_filter_embed(), view=self)
|
||||
user_select.callback = user_callback
|
||||
self.add_item(user_select)
|
||||
current_row += 1
|
||||
|
||||
role_select = disnake.ui.RoleSelect(
|
||||
placeholder="Filtrer par rôle auteur...",
|
||||
min_values=0,
|
||||
max_values=1,
|
||||
custom_id="reports_filter_role",
|
||||
row=current_row
|
||||
)
|
||||
async def role_callback(inter: disnake.MessageInteraction):
|
||||
if inter.values:
|
||||
val = inter.values[0]
|
||||
if isinstance(val, str):
|
||||
self.selected_role = inter.guild.get_role(int(val)) or val
|
||||
else:
|
||||
self.selected_role = val
|
||||
else:
|
||||
self.selected_role = None
|
||||
self.update_components()
|
||||
await inter.response.edit_message(embed=self.get_filter_embed(), view=self)
|
||||
role_select.callback = role_callback
|
||||
self.add_item(role_select)
|
||||
current_row += 1
|
||||
|
||||
# Row 4: Boutons Recherche / Reset
|
||||
search_btn = disnake.ui.Button(
|
||||
label="Rechercher",
|
||||
style=disnake.ButtonStyle.green,
|
||||
emoji="🔍",
|
||||
custom_id="reports_filter_search",
|
||||
row=current_row
|
||||
)
|
||||
async def search_callback(inter: disnake.MessageInteraction):
|
||||
author_filter = None
|
||||
if not self.has_global_access:
|
||||
author_filter = self.viewer_id
|
||||
elif self.selected_user:
|
||||
author_filter = self.selected_user.id if hasattr(self.selected_user, "id") else int(self.selected_user)
|
||||
|
||||
role_filter_id = None
|
||||
if self.selected_role:
|
||||
role_filter_id = self.selected_role.id if hasattr(self.selected_role, "id") else int(self.selected_role)
|
||||
|
||||
reports = self.cog.db.get_reports(
|
||||
guild_id=self.guild_id,
|
||||
author_id=author_filter,
|
||||
subject=self.selected_subject,
|
||||
status=self.selected_status,
|
||||
role_id=role_filter_id
|
||||
)
|
||||
|
||||
if not reports:
|
||||
await inter.response.send_message("❌ Aucun rapport ne correspond à votre recherche.", ephemeral=True)
|
||||
return
|
||||
|
||||
browser = ReportsBrowserView(self.cog, self.interaction, reports, filter_view=self)
|
||||
await inter.response.edit_message(embed=browser.get_embed(), view=browser)
|
||||
|
||||
search_btn.callback = search_callback
|
||||
self.add_item(search_btn)
|
||||
|
||||
reset_btn = disnake.ui.Button(
|
||||
label="Réinitialiser",
|
||||
style=disnake.ButtonStyle.red,
|
||||
emoji="❌",
|
||||
custom_id="reports_filter_reset",
|
||||
row=current_row
|
||||
)
|
||||
async def reset_callback(inter: disnake.MessageInteraction):
|
||||
self.selected_user = None
|
||||
self.selected_role = None
|
||||
self.selected_status = None
|
||||
self.selected_subject = None
|
||||
self.update_components()
|
||||
await inter.response.edit_message(embed=self.get_filter_embed(), view=self)
|
||||
reset_btn.callback = reset_callback
|
||||
self.add_item(reset_btn)
|
||||
|
||||
def get_filter_embed(self) -> disnake.Embed:
|
||||
"""Génère l'embed résumant les filtres actifs."""
|
||||
author = self.interaction.author
|
||||
lines = []
|
||||
lines.append(f"🚦 **Statut** : {self.selected_status or 'Tous'}")
|
||||
if self.has_global_access:
|
||||
if self.selected_user:
|
||||
mention = self.selected_user.mention if hasattr(self.selected_user, 'mention') else f'<@{self.selected_user}>'
|
||||
lines.append(f"👤 **Auteur** : {mention}")
|
||||
else:
|
||||
lines.append("👤 **Auteur** : Tous")
|
||||
if self.selected_role:
|
||||
mention = self.selected_role.mention if hasattr(self.selected_role, 'mention') else f'<@&{self.selected_role}>'
|
||||
lines.append(f"🛡️ **Rôle Auteur** : {mention}")
|
||||
else:
|
||||
lines.append("🛡️ **Rôle Auteur** : Tous")
|
||||
else:
|
||||
lines.append(f"👤 **Auteur** : {author.mention} *(accès limité à vos rapports)*")
|
||||
lines.append(f"📁 **Objet / Catégorie** : {self.selected_subject or 'Tous'}")
|
||||
embed = disnake.Embed(
|
||||
title="🔍 Recherche & Filtres de Rapports",
|
||||
description="\n".join(lines),
|
||||
color=disnake.Color.blurple()
|
||||
)
|
||||
embed.set_thumbnail(url=author.display_avatar.url)
|
||||
return embed
|
||||
|
||||
def get_components_v2(self):
|
||||
"""Kept for backward compatibility — returns view children only."""
|
||||
return self.children
|
||||
|
||||
async def interaction_check(self, interaction: disnake.MessageInteraction) -> bool:
|
||||
if interaction.author.id != self.viewer_id:
|
||||
await interaction.response.send_message("❌ Vous ne pouvez pas interagir avec ce menu.", ephemeral=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
class ReportsBrowserView(disnake.ui.View):
|
||||
def __init__(self, cog, interaction, reports, filter_view=None, current_index=0):
|
||||
super().__init__(timeout=600)
|
||||
self.cog = cog
|
||||
self.interaction = interaction
|
||||
self.guild_id = interaction.guild.id
|
||||
self.viewer_id = interaction.author.id
|
||||
self.is_admin = interaction.author.guild_permissions.administrator
|
||||
self.reports = reports
|
||||
self.filter_view = filter_view
|
||||
self.current_index = current_index
|
||||
self.page_index = current_index // 25
|
||||
self.update_components()
|
||||
|
||||
def update_components(self):
|
||||
self.clear_items()
|
||||
|
||||
if not self.reports:
|
||||
return
|
||||
|
||||
report = self.reports[self.current_index]
|
||||
|
||||
# Row 0: StringSelectMenu de selection du rapport
|
||||
total_pages = (len(self.reports) + 24) // 25
|
||||
start = self.page_index * 25
|
||||
end = min(start + 25, len(self.reports))
|
||||
|
||||
options = []
|
||||
for i in range(start, end):
|
||||
rep = self.reports[i]
|
||||
title = rep['title']
|
||||
if len(title) > 60:
|
||||
title = title[:57] + "..."
|
||||
label = f"#{rep['id']} - {title}"
|
||||
description = f"Statut: {rep['status']} | Objet: {rep['subject']}"
|
||||
if len(description) > 100:
|
||||
description = description[:97] + "..."
|
||||
options.append(
|
||||
disnake.SelectOption(
|
||||
label=label,
|
||||
description=description,
|
||||
value=str(i),
|
||||
default=(i == self.current_index)
|
||||
)
|
||||
)
|
||||
|
||||
select_menu = disnake.ui.StringSelect(
|
||||
placeholder="Sélectionnez un rapport à lire...",
|
||||
options=options,
|
||||
custom_id="reports_browser_select",
|
||||
row=0
|
||||
)
|
||||
|
||||
async def select_callback(inter: disnake.MessageInteraction):
|
||||
self.current_index = int(inter.values[0])
|
||||
self.page_index = self.current_index // 25
|
||||
self.update_components()
|
||||
await inter.response.edit_message(embed=self.get_embed(), view=self)
|
||||
|
||||
select_menu.callback = select_callback
|
||||
self.add_item(select_menu)
|
||||
|
||||
# Row 1: Boutons de pagination de pages (si > 25 rapports)
|
||||
if total_pages > 1:
|
||||
prev_btn = disnake.ui.Button(
|
||||
label="⬅️ Page précédente",
|
||||
style=disnake.ButtonStyle.secondary,
|
||||
disabled=(self.page_index == 0),
|
||||
custom_id="reports_browser_prev_page",
|
||||
row=1
|
||||
)
|
||||
next_btn = disnake.ui.Button(
|
||||
label="➡️ Page suivante",
|
||||
style=disnake.ButtonStyle.secondary,
|
||||
disabled=(self.page_index >= total_pages - 1),
|
||||
custom_id="reports_browser_next_page",
|
||||
row=1
|
||||
)
|
||||
|
||||
async def prev_callback(inter: disnake.MessageInteraction):
|
||||
self.page_index -= 1
|
||||
self.current_index = self.page_index * 25
|
||||
self.update_components()
|
||||
await inter.response.edit_message(embed=self.get_embed(), view=self)
|
||||
|
||||
async def next_callback(inter: disnake.MessageInteraction):
|
||||
self.page_index += 1
|
||||
self.current_index = self.page_index * 25
|
||||
self.update_components()
|
||||
await inter.response.edit_message(embed=self.get_embed(), view=self)
|
||||
|
||||
prev_btn.callback = prev_callback
|
||||
next_btn.callback = next_callback
|
||||
self.add_item(prev_btn)
|
||||
self.add_item(next_btn)
|
||||
|
||||
# Row 2: Boutons d'Action (Modifier, Supprimer, Retour)
|
||||
is_author = (self.viewer_id == report['author_id'])
|
||||
is_blocked = report['status'] in ['Traité', 'Classé']
|
||||
|
||||
edit_disabled = not is_author or is_blocked
|
||||
delete_disabled = not (is_author or self.is_admin) or is_blocked
|
||||
|
||||
edit_btn = disnake.ui.Button(
|
||||
label="Modifier",
|
||||
style=disnake.ButtonStyle.primary,
|
||||
emoji="✏️",
|
||||
disabled=edit_disabled,
|
||||
custom_id="reports_browser_edit",
|
||||
row=2
|
||||
)
|
||||
delete_btn = disnake.ui.Button(
|
||||
label="Supprimer",
|
||||
style=disnake.ButtonStyle.danger,
|
||||
emoji="🗑️",
|
||||
disabled=delete_disabled,
|
||||
custom_id="reports_browser_delete",
|
||||
row=2
|
||||
)
|
||||
back_btn = disnake.ui.Button(
|
||||
label="Retour aux filtres",
|
||||
style=disnake.ButtonStyle.secondary,
|
||||
emoji="⬅️",
|
||||
custom_id="reports_browser_back",
|
||||
row=2
|
||||
)
|
||||
|
||||
async def edit_callback(inter: disnake.MessageInteraction):
|
||||
report_fresh = self.cog.db.get_report(report['id'])
|
||||
if not report_fresh:
|
||||
return await inter.response.send_message("❌ Ce rapport n'existe plus.", ephemeral=True)
|
||||
if report_fresh['status'] in ['Traité', 'Classé']:
|
||||
return await inter.response.send_message("❌ Ce rapport a été traité ou classé et ne peut plus être modifié.", ephemeral=True)
|
||||
if inter.author.id != report_fresh['author_id']:
|
||||
return await inter.response.send_message("❌ Seul l'auteur du rapport peut le modifier.", ephemeral=True)
|
||||
|
||||
modal = EditReportModal(self.cog, report_fresh['id'], self)
|
||||
await inter.response.send_modal(modal)
|
||||
|
||||
async def delete_callback(inter: disnake.MessageInteraction):
|
||||
report_fresh = self.cog.db.get_report(report['id'])
|
||||
if not report_fresh:
|
||||
return await inter.response.send_message("❌ Ce rapport n'existe plus.", ephemeral=True)
|
||||
if report_fresh['status'] in ['Traité', 'Classé']:
|
||||
return await inter.response.send_message("❌ Ce rapport a été traité ou classé et ne peut pas être supprimé.", ephemeral=True)
|
||||
is_auth = (inter.author.id == report_fresh['author_id'])
|
||||
if not (is_auth or inter.author.guild_permissions.administrator):
|
||||
return await inter.response.send_message("❌ Vous n'avez pas la permission de supprimer ce rapport.", ephemeral=True)
|
||||
|
||||
self.cog.db.delete_report(report['id'])
|
||||
del self.reports[self.current_index]
|
||||
|
||||
if not self.reports:
|
||||
if self.filter_view:
|
||||
self.filter_view.update_components()
|
||||
await inter.response.edit_message(
|
||||
embed=self.filter_view.get_filter_embed(), view=self.filter_view
|
||||
)
|
||||
else:
|
||||
await inter.response.edit_message(
|
||||
content="✅ Le rapport a été supprimé. Plus aucun rapport dans cette recherche.",
|
||||
embed=None, view=None
|
||||
)
|
||||
return
|
||||
|
||||
if self.current_index >= len(self.reports):
|
||||
self.current_index = len(self.reports) - 1
|
||||
self.page_index = self.current_index // 25
|
||||
|
||||
self.update_components()
|
||||
await inter.response.edit_message(embed=self.get_embed(), view=self)
|
||||
await inter.followup.send("✅ Rapport supprimé avec succès.", ephemeral=True)
|
||||
|
||||
async def back_callback(inter: disnake.MessageInteraction):
|
||||
if self.filter_view:
|
||||
self.filter_view.update_components()
|
||||
await inter.response.edit_message(
|
||||
embed=self.filter_view.get_filter_embed(), view=self.filter_view
|
||||
)
|
||||
else:
|
||||
new_filter_view = ReportsFilterView(self.cog, self.interaction)
|
||||
await inter.response.edit_message(
|
||||
embed=new_filter_view.get_filter_embed(), view=new_filter_view
|
||||
)
|
||||
|
||||
edit_btn.callback = edit_callback
|
||||
delete_btn.callback = delete_callback
|
||||
back_btn.callback = back_callback
|
||||
self.add_item(edit_btn)
|
||||
self.add_item(delete_btn)
|
||||
self.add_item(back_btn)
|
||||
|
||||
# Row 3: Menu Déroulant "Changer l'état" (Admin uniquement)
|
||||
if self.is_admin:
|
||||
status_options = [
|
||||
disnake.SelectOption(label="En attente", emoji="🟡", value="En attente"),
|
||||
disnake.SelectOption(label="Traité", emoji="🟢", value="Traité"),
|
||||
disnake.SelectOption(label="Classé", emoji="🔴", value="Classé")
|
||||
]
|
||||
status_menu = disnake.ui.StringSelect(
|
||||
placeholder="Changer le statut...",
|
||||
options=status_options,
|
||||
custom_id="reports_browser_status",
|
||||
row=3
|
||||
)
|
||||
|
||||
async def status_callback(inter: disnake.MessageInteraction):
|
||||
if not inter.author.guild_permissions.administrator:
|
||||
return await inter.response.send_message("❌ Seuls les administrateurs peuvent modifier le statut.", ephemeral=True)
|
||||
|
||||
old_status = report['status']
|
||||
new_status = inter.values[0]
|
||||
|
||||
if old_status == new_status:
|
||||
return await inter.response.send_message(f"ℹ️ Le rapport est déjà en statut **{new_status}**.", ephemeral=True)
|
||||
|
||||
self.cog.db.update_report_status(report['id'], new_status)
|
||||
updated_rep = self.cog.db.get_report(report['id'])
|
||||
self.reports[self.current_index] = updated_rep
|
||||
|
||||
self.update_components()
|
||||
await inter.response.edit_message(embed=self.get_embed(), view=self)
|
||||
await inter.followup.send(
|
||||
f"✅ Statut du rapport **#{report['id']}** mis à jour : **{old_status}** → **{new_status}**.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
# === Notification DM à l'auteur ===
|
||||
status_colors = {
|
||||
"En attente": disnake.Color.yellow(),
|
||||
"Traité": disnake.Color.green(),
|
||||
"Classé": disnake.Color.greyple(),
|
||||
}
|
||||
status_emojis = {"En attente": "🟡", "Traité": "🟢", "Classé": "🔴"}
|
||||
dm_sent = False
|
||||
try:
|
||||
author_member = inter.guild.get_member(report['author_id'])
|
||||
if not author_member:
|
||||
author_member = await inter.guild.fetch_member(report['author_id'])
|
||||
|
||||
if author_member:
|
||||
dm_embed = disnake.Embed(
|
||||
title="📋 Mise à jour de votre rapport",
|
||||
description=(
|
||||
f"Le statut de votre rapport **#{report['id']} — {report['title']}** "
|
||||
f"vient d'être modifié."
|
||||
),
|
||||
color=status_colors.get(new_status, disnake.Color.blurple()),
|
||||
timestamp=datetime.now(PARIS_TZ)
|
||||
)
|
||||
dm_embed.add_field(
|
||||
name="Ancien statut",
|
||||
value=f"{status_emojis.get(old_status, '⚪')} {old_status}",
|
||||
inline=True
|
||||
)
|
||||
dm_embed.add_field(
|
||||
name="Nouveau statut",
|
||||
value=f"{status_emojis.get(new_status, '⚪')} {new_status}",
|
||||
inline=True
|
||||
)
|
||||
dm_embed.add_field(name="Modifié par", value=inter.author.mention, inline=False)
|
||||
dm_embed.add_field(name="Serveur", value=inter.guild.name, inline=False)
|
||||
dm_embed.set_footer(text=f"Rapport #{report['id']} • {inter.guild.name}")
|
||||
if inter.guild.icon:
|
||||
dm_embed.set_thumbnail(url=inter.guild.icon.url)
|
||||
try:
|
||||
await author_member.send(embed=dm_embed)
|
||||
dm_sent = True
|
||||
except disnake.Forbidden:
|
||||
kuby_logger.info(
|
||||
f"[Rapports] DM impossible pour {author_member} (DMs fermés) — rapport #{report['id']}"
|
||||
)
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"[Rapports] Erreur DM notification: {e}", exc_info=True)
|
||||
except disnake.NotFound:
|
||||
pass
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"[Rapports] Erreur récupération membre pour DM: {e}", exc_info=True)
|
||||
|
||||
dm_note = " • 📬 Auteur notifié par DM." if dm_sent else " • ⚠️ DM non envoyé (DMs fermés ou membre introuvable)."
|
||||
await inter.followup.send(
|
||||
f"ℹ️{dm_note}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
status_menu.callback = status_callback
|
||||
self.add_item(status_menu)
|
||||
|
||||
def get_embed(self):
|
||||
if not self.reports:
|
||||
return disnake.Embed(
|
||||
title="Aucun rapport disponible",
|
||||
description="Il n'y a aucun rapport correspondant aux critères.",
|
||||
color=disnake.Color.red()
|
||||
)
|
||||
|
||||
report = self.reports[self.current_index]
|
||||
|
||||
status_colors = {
|
||||
"En attente": 0xF1C40F,
|
||||
"Traité": 0x2ECC71,
|
||||
"Classé": 0x95A5A6
|
||||
}
|
||||
color = status_colors.get(report['status'], 0x3498DB)
|
||||
|
||||
status_emojis = {
|
||||
"En attente": "🟡 En attente",
|
||||
"Traité": "🟢 Traité",
|
||||
"Classé": "🔴 Classé"
|
||||
}
|
||||
status_display = status_emojis.get(report['status'], report['status'])
|
||||
|
||||
embed = disnake.Embed(
|
||||
title=f"Rapport #{report['id']} : {report['title']}",
|
||||
description=report['content'],
|
||||
color=color
|
||||
)
|
||||
embed.add_field(name="📁 Objet / Catégorie", value=report['subject'], inline=True)
|
||||
embed.add_field(name="🚦 Statut", value=status_display, inline=True)
|
||||
embed.add_field(name="👤 Auteur", value=f"<@{report['author_id']}>", inline=True)
|
||||
|
||||
try:
|
||||
created_dt = datetime.fromisoformat(report['created_at'])
|
||||
created_ts = int(created_dt.timestamp())
|
||||
embed.add_field(name="📅 Date de création", value=f"<t:{created_ts}:F> (<t:{created_ts}:R>)", inline=False)
|
||||
except Exception:
|
||||
embed.add_field(name="📅 Date de création", value=report['created_at'], inline=False)
|
||||
|
||||
if report['last_edited_at']:
|
||||
try:
|
||||
edited_dt = datetime.fromisoformat(report['last_edited_at'])
|
||||
edited_str = edited_dt.strftime("%d/%m/%Y à %H:%M")
|
||||
embed.set_footer(text=f"Dernière action le {edited_str}")
|
||||
except Exception:
|
||||
embed.set_footer(text=f"Dernière action le {report['last_edited_at']}")
|
||||
else:
|
||||
embed.set_footer(text="Rapports Hybrides")
|
||||
|
||||
return embed
|
||||
|
||||
async def interaction_check(self, interaction: disnake.MessageInteraction) -> bool:
|
||||
if interaction.author.id != self.viewer_id:
|
||||
await interaction.response.send_message("❌ Vous ne pouvez pas interagir avec ce menu.", ephemeral=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
class Rapports(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
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.db = ReportsDB(self.db_path)
|
||||
self.retention_loop.start()
|
||||
|
||||
def cog_unload(self):
|
||||
self.retention_loop.cancel()
|
||||
|
||||
@tasks.loop(hours=24)
|
||||
async def retention_loop(self):
|
||||
try:
|
||||
deleted_count = self.db.purge_automatic()
|
||||
if deleted_count > 0:
|
||||
kuby_logger.info(f"🧹 [Rétention] Nettoyage automatique effectué : {deleted_count} rapports de plus de 90 jours (non classés) supprimés.")
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"❌ [Rétention] Erreur lors du nettoyage automatique: {e}", exc_info=True)
|
||||
|
||||
@retention_loop.before_loop
|
||||
async def before_retention_loop(self):
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
@commands.slash_command(name="config-system", description="Configuration du module de rapports (Admin uniquement)")
|
||||
@check_premium_tier()
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def config_system(self, interaction: disnake.ApplicationCommandInteraction, role_viewer: disnake.Role = None):
|
||||
guild_id = interaction.guild.id
|
||||
|
||||
if role_viewer:
|
||||
self.db.set_config(guild_id, role_viewer.id)
|
||||
viewer_text = f"{role_viewer.mention}"
|
||||
else:
|
||||
viewer_role_id = self.db.get_config(guild_id)
|
||||
viewer_text = f"<@&{viewer_role_id}>" if viewer_role_id else "Aucun rôle configuré"
|
||||
|
||||
embed = disnake.Embed(
|
||||
title="⚙️ Configuration du Système de Rapports",
|
||||
description="Gérez les autorisations de consultation globale et effectuez la maintenance.",
|
||||
color=disnake.Color.blue()
|
||||
)
|
||||
embed.add_field(
|
||||
name="👁️ Rôle Lecteur (Viewer)",
|
||||
value=f"{viewer_text}\n*(Les membres ayant ce rôle peuvent consulter tous les rapports du serveur)*",
|
||||
inline=False
|
||||
)
|
||||
embed.add_field(
|
||||
name="🧹 Maintenance & Rétention",
|
||||
value="Les rapports vieux de plus de **90 jours** sont automatiquement nettoyés chaque jour, sauf s'ils sont **Classés**.\n\nVous pouvez également lancer une purge manuelle pour supprimer les rapports de plus de **7 jours** non classés.",
|
||||
inline=False
|
||||
)
|
||||
embed.set_footer(text="Système de Rapports Hybride")
|
||||
|
||||
view = ReportsConfigView(self, guild_id)
|
||||
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
|
||||
|
||||
@commands.slash_command(name="rapport", description="Ouvrir le formulaire de saisie de rapport")
|
||||
@check_premium_tier()
|
||||
async def rapport(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
await interaction.response.send_modal(ReportModal(self))
|
||||
|
||||
@commands.slash_command(name="consulter_rapport", description="Consulter les rapports du serveur via une interface interactive")
|
||||
@check_premium_tier()
|
||||
async def consulter_rapport(
|
||||
self,
|
||||
interaction: disnake.ApplicationCommandInteraction,
|
||||
utilisateur: disnake.Member = None,
|
||||
categorie: str = None,
|
||||
statut: str = commands.Param(None, choices=["En attente", "Traité", "Classé"]),
|
||||
role: disnake.Role = None
|
||||
):
|
||||
filter_view = ReportsFilterView(self, interaction)
|
||||
if utilisateur:
|
||||
filter_view.selected_user = utilisateur
|
||||
if categorie:
|
||||
filter_view.selected_subject = categorie
|
||||
if statut:
|
||||
filter_view.selected_status = statut
|
||||
if role:
|
||||
filter_view.selected_role = role
|
||||
|
||||
filter_view.update_components()
|
||||
await interaction.response.send_message(
|
||||
embed=disnake.Embed(
|
||||
title="🔍 Recherche & Filtres de Rapports",
|
||||
description=(
|
||||
f"🚦 **Statut** : {filter_view.selected_status or 'Tous'}\n"
|
||||
f"📁 **Objet / Catégorie** : {filter_view.selected_subject or 'Tous'}\n"
|
||||
+ (f"👤 **Auteur** : {filter_view.selected_user.mention if hasattr(filter_view.selected_user, 'mention') else 'Tous'}\n" if filter_view.has_global_access else f"👤 **Auteur** : {interaction.author.mention} *(accès limité à vos rapports)*\n")
|
||||
+ (f"🛡️ **Rôle Auteur** : {filter_view.selected_role.mention if hasattr(filter_view.selected_role, 'mention') else 'Tous'}" if filter_view.has_global_access else "")
|
||||
),
|
||||
color=disnake.Color.blurple()
|
||||
),
|
||||
view=filter_view,
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
def setup(bot):
|
||||
bot.add_cog(Rapports(bot))
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
# scan.py
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
import disnake
|
||||
from disnake.ext import commands
|
||||
import hashlib
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
MALWARE_DB = "malware.json"
|
||||
SUSPICIOUS_EXTENSIONS = [".exe", ".scr", ".bat", ".js", ".vbs", ".cmd"]
|
||||
|
|
@ -59,8 +59,9 @@ class Scan(commands.Cog):
|
|||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
@app_commands.command(name="scan", description="Scanner un fichier pour détecter un malware.")
|
||||
async def scan(self, interaction: discord.Interaction, file: discord.Attachment):
|
||||
@commands.slash_command(name="scan", description="Scanner un fichier pour détecter un malware.")
|
||||
@check_premium_tier()
|
||||
async def scan(self, interaction: disnake.ApplicationCommandInteraction, file: disnake.Attachment):
|
||||
await interaction.response.defer() # Permet de prendre un peu de temps pour le scan
|
||||
|
||||
# Téléchargement temporaire du fichier
|
||||
|
|
@ -78,5 +79,5 @@ class Scan(commands.Cog):
|
|||
await interaction.followup.send(f"✅ Fichier sain.\nHash : `{result['hash']}`")
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(Scan(bot))
|
||||
def setup(bot):
|
||||
bot.add_cog(Scan(bot))
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
import disnake
|
||||
from disnake.ext import commands
|
||||
import json
|
||||
import os
|
||||
from typing import Optional
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
# 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:
|
||||
|
|
@ -32,7 +37,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)
|
||||
|
||||
|
|
@ -70,9 +75,9 @@ def is_immune(guild, user) -> bool:
|
|||
|
||||
# --- MODALS DE CONFIGURATION ---
|
||||
|
||||
class SpamThresholdModal(discord.ui.Modal, title="🛡️ Seuil Anti-Spam"):
|
||||
class SpamThresholdModal(disnake.ui.Modal):
|
||||
"""Modal pour configurer le seuil anti-spam"""
|
||||
threshold_input = discord.ui.TextInput(
|
||||
threshold_input = disnake.ui.TextInput(
|
||||
label="Messages max autorisés",
|
||||
placeholder="Entre 3 et 10 (défaut: 5)",
|
||||
min_length=1,
|
||||
|
|
@ -81,12 +86,12 @@ class SpamThresholdModal(discord.ui.Modal, title="🛡️ Seuil Anti-Spam"):
|
|||
)
|
||||
|
||||
def __init__(self, guild_id: int, settings: dict, view: 'SecurityView'):
|
||||
super().__init__()
|
||||
super().__init__(title="🛡️ Seuil Anti-Spam", components=[self.threshold_input])
|
||||
self.guild_id = guild_id
|
||||
self.settings = settings
|
||||
self.parent_view = view
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
async def callback(self, interaction: disnake.Interaction):
|
||||
if not self.threshold_input.value.isdigit():
|
||||
return await interaction.response.send_message(
|
||||
"❌ Erreur : Veuillez entrer un nombre entier.",
|
||||
|
|
@ -111,9 +116,9 @@ class SpamThresholdModal(discord.ui.Modal, title="🛡️ Seuil Anti-Spam"):
|
|||
ephemeral=True
|
||||
)
|
||||
|
||||
class RaidThresholdModal(discord.ui.Modal, title="⚔️ Seuil Anti-Raid"):
|
||||
class RaidThresholdModal(disnake.ui.Modal):
|
||||
"""Modal pour configurer le seuil anti-raid"""
|
||||
threshold_input = discord.ui.TextInput(
|
||||
threshold_input = disnake.ui.TextInput(
|
||||
label="Joins max en 10 secondes",
|
||||
placeholder="Entre 2 et 10 (défaut: 3)",
|
||||
min_length=1,
|
||||
|
|
@ -122,12 +127,12 @@ class RaidThresholdModal(discord.ui.Modal, title="⚔️ Seuil Anti-Raid"):
|
|||
)
|
||||
|
||||
def __init__(self, guild_id: int, settings: dict, view: 'SecurityView'):
|
||||
super().__init__()
|
||||
super().__init__(title="⚔️ Seuil Anti-Raid", components=[self.threshold_input])
|
||||
self.guild_id = guild_id
|
||||
self.settings = settings
|
||||
self.parent_view = view
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
async def callback(self, interaction: disnake.Interaction):
|
||||
if not self.threshold_input.value.isdigit():
|
||||
return await interaction.response.send_message(
|
||||
"❌ Erreur : Veuillez entrer un nombre entier.",
|
||||
|
|
@ -154,9 +159,9 @@ class RaidThresholdModal(discord.ui.Modal, title="⚔️ Seuil Anti-Raid"):
|
|||
|
||||
# --- MODALS DE CONFIGURATION WHITELIST ---
|
||||
|
||||
class WhitelistModal(discord.ui.Modal, title="👤 Ajouter à la Whitelist"):
|
||||
class WhitelistModal(disnake.ui.Modal):
|
||||
"""Modal pour ajouter un utilisateur à la whitelist"""
|
||||
user_input = discord.ui.TextInput(
|
||||
user_input = disnake.ui.TextInput(
|
||||
label="ID de l'utilisateur",
|
||||
placeholder="Entrez l'ID Discord de l'utilisateur",
|
||||
min_length=15,
|
||||
|
|
@ -165,11 +170,11 @@ class WhitelistModal(discord.ui.Modal, title="👤 Ajouter à la Whitelist"):
|
|||
)
|
||||
|
||||
def __init__(self, guild_id: int, view: 'SecurityView'):
|
||||
super().__init__()
|
||||
super().__init__(title="👤 Ajouter à la Whitelist", components=[self.user_input])
|
||||
self.guild_id = guild_id
|
||||
self.parent_view = view
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
async def callback(self, interaction: disnake.Interaction):
|
||||
if not self.user_input.value.isdigit():
|
||||
return await interaction.response.send_message(
|
||||
"❌ Erreur : ID invalide.",
|
||||
|
|
@ -213,10 +218,10 @@ class WhitelistModal(discord.ui.Modal, title="👤 Ajouter à la Whitelist"):
|
|||
|
||||
# --- VUES DE L'INTERFACE ---
|
||||
|
||||
class SecurityView(discord.ui.View):
|
||||
class SecurityView(disnake.ui.View):
|
||||
"""Vue principale du panneau de sécurité"""
|
||||
|
||||
def __init__(self, interaction: discord.Interaction, settings: dict, category: Optional[str] = None, bot=None):
|
||||
def __init__(self, interaction: disnake.Interaction, settings: dict, category: Optional[str] = None, bot=None):
|
||||
super().__init__(timeout=300) # 5 minutes timeout
|
||||
self.interaction = interaction
|
||||
self.guild = interaction.guild
|
||||
|
|
@ -245,6 +250,10 @@ class SecurityView(discord.ui.View):
|
|||
self.build_whitelist_view()
|
||||
elif self.category == "rules":
|
||||
self.build_rules_view()
|
||||
elif self.category == "membres":
|
||||
self.build_members_view()
|
||||
elif self.category == "taches":
|
||||
self.build_taches_view()
|
||||
|
||||
def build_spam_view(self):
|
||||
"""Construit la vue Anti-Spam"""
|
||||
|
|
@ -270,18 +279,18 @@ class SecurityView(discord.ui.View):
|
|||
|
||||
# Bouton de configuration du seuil
|
||||
threshold = self.settings.get("spam_threshold", 5)
|
||||
btn_threshold = discord.ui.Button(
|
||||
btn_threshold = disnake.ui.Button(
|
||||
label=f"🔢 Seuil: {threshold} msg/5s",
|
||||
style=discord.ButtonStyle.blurple,
|
||||
style=disnake.ButtonStyle.blurple,
|
||||
custom_id="spam_threshold"
|
||||
)
|
||||
btn_threshold.callback = self.open_spam_threshold_modal
|
||||
self.add_item(btn_threshold)
|
||||
|
||||
# Bouton retour
|
||||
btn_back = discord.ui.Button(
|
||||
btn_back = disnake.ui.Button(
|
||||
label="Retour",
|
||||
style=discord.ButtonStyle.gray,
|
||||
style=disnake.ButtonStyle.gray,
|
||||
emoji="⬅️"
|
||||
)
|
||||
btn_back.callback = self.back_to_main
|
||||
|
|
@ -311,18 +320,18 @@ class SecurityView(discord.ui.View):
|
|||
|
||||
# Bouton de configuration du seuil
|
||||
threshold = self.settings.get("raid_threshold", 3)
|
||||
btn_threshold = discord.ui.Button(
|
||||
btn_threshold = disnake.ui.Button(
|
||||
label=f"🔢 Seuil: {threshold} join/10s",
|
||||
style=discord.ButtonStyle.blurple,
|
||||
style=disnake.ButtonStyle.blurple,
|
||||
custom_id="raid_threshold"
|
||||
)
|
||||
btn_threshold.callback = self.open_raid_threshold_modal
|
||||
self.add_item(btn_threshold)
|
||||
|
||||
# Bouton retour
|
||||
btn_back = discord.ui.Button(
|
||||
btn_back = disnake.ui.Button(
|
||||
label="Retour",
|
||||
style=discord.ButtonStyle.gray,
|
||||
style=disnake.ButtonStyle.gray,
|
||||
emoji="⬅️"
|
||||
)
|
||||
btn_back.callback = self.back_to_main
|
||||
|
|
@ -351,9 +360,9 @@ class SecurityView(discord.ui.View):
|
|||
self.add_item(btn_role)
|
||||
|
||||
# Bouton retour
|
||||
btn_back = discord.ui.Button(
|
||||
btn_back = disnake.ui.Button(
|
||||
label="Retour",
|
||||
style=discord.ButtonStyle.gray,
|
||||
style=disnake.ButtonStyle.gray,
|
||||
emoji="⬅️"
|
||||
)
|
||||
btn_back.callback = self.back_to_main
|
||||
|
|
@ -376,23 +385,23 @@ class SecurityView(discord.ui.View):
|
|||
if log_channel_id:
|
||||
channel = self.guild.get_channel(int(log_channel_id))
|
||||
channel_mention = channel.mention if channel else f"`{log_channel_id}`"
|
||||
btn_channel = discord.ui.Button(
|
||||
btn_channel = disnake.ui.Button(
|
||||
label=f"📌 Channel: {channel.name if channel else 'Invalide'}",
|
||||
style=discord.ButtonStyle.green,
|
||||
style=disnake.ButtonStyle.green,
|
||||
disabled=True
|
||||
)
|
||||
else:
|
||||
btn_channel = discord.ui.Button(
|
||||
btn_channel = disnake.ui.Button(
|
||||
label="📌 Aucun channel configuré",
|
||||
style=discord.ButtonStyle.red,
|
||||
style=disnake.ButtonStyle.red,
|
||||
disabled=True
|
||||
)
|
||||
self.add_item(btn_channel)
|
||||
|
||||
# Bouton retour
|
||||
btn_back = discord.ui.Button(
|
||||
btn_back = disnake.ui.Button(
|
||||
label="Retour",
|
||||
style=discord.ButtonStyle.gray,
|
||||
style=disnake.ButtonStyle.gray,
|
||||
emoji="⬅️"
|
||||
)
|
||||
btn_back.callback = self.back_to_main
|
||||
|
|
@ -401,9 +410,9 @@ class SecurityView(discord.ui.View):
|
|||
def build_whitelist_view(self):
|
||||
"""Construit la vue Whitelist"""
|
||||
# Affichage du nombre avec style
|
||||
btn_count = discord.ui.Button(
|
||||
btn_count = disnake.ui.Button(
|
||||
label=f"👥 Whitelist: {self.whitelist_count} utilisateur(s)",
|
||||
style=discord.ButtonStyle.blurple,
|
||||
style=disnake.ButtonStyle.blurple,
|
||||
disabled=True
|
||||
)
|
||||
self.add_item(btn_count)
|
||||
|
|
@ -413,17 +422,17 @@ class SecurityView(discord.ui.View):
|
|||
self.add_item(WhitelistSelect(self.guild, self))
|
||||
else:
|
||||
# Message si whitelist vide
|
||||
btn_empty = discord.ui.Button(
|
||||
btn_empty = disnake.ui.Button(
|
||||
label="📭 Aucun utilisateur dans la whitelist",
|
||||
style=discord.ButtonStyle.gray,
|
||||
style=disnake.ButtonStyle.gray,
|
||||
disabled=True
|
||||
)
|
||||
self.add_item(btn_empty)
|
||||
|
||||
# Bouton ajouter
|
||||
btn_add = discord.ui.Button(
|
||||
btn_add = disnake.ui.Button(
|
||||
label="➕ Ajouter",
|
||||
style=discord.ButtonStyle.green,
|
||||
style=disnake.ButtonStyle.green,
|
||||
emoji="➕",
|
||||
custom_id="whitelist_add"
|
||||
)
|
||||
|
|
@ -431,9 +440,9 @@ class SecurityView(discord.ui.View):
|
|||
self.add_item(btn_add)
|
||||
|
||||
# Bouton retour
|
||||
btn_back = discord.ui.Button(
|
||||
btn_back = disnake.ui.Button(
|
||||
label="Retour",
|
||||
style=discord.ButtonStyle.gray,
|
||||
style=disnake.ButtonStyle.gray,
|
||||
emoji="⬅️"
|
||||
)
|
||||
btn_back.callback = self.back_to_main
|
||||
|
|
@ -458,18 +467,18 @@ class SecurityView(discord.ui.View):
|
|||
|
||||
|
||||
# Bouton pour modifier le titre
|
||||
btn_title = discord.ui.Button(
|
||||
btn_title = disnake.ui.Button(
|
||||
label="Éditer le titre",
|
||||
style=discord.ButtonStyle.blurple,
|
||||
style=disnake.ButtonStyle.blurple,
|
||||
emoji="📝"
|
||||
)
|
||||
btn_title.callback = self.open_rules_title_modal
|
||||
self.add_item(btn_title)
|
||||
|
||||
# Bouton pour modifier le contenu
|
||||
btn_content = discord.ui.Button(
|
||||
btn_content = disnake.ui.Button(
|
||||
label="Éditer le contenu",
|
||||
style=discord.ButtonStyle.blurple,
|
||||
style=disnake.ButtonStyle.blurple,
|
||||
emoji="📄"
|
||||
)
|
||||
btn_content.callback = self.open_rules_content_modal
|
||||
|
|
@ -479,15 +488,15 @@ class SecurityView(discord.ui.View):
|
|||
rules_channel_id = self.settings.get("rules_channel_id")
|
||||
if rules_channel_id:
|
||||
channel = self.guild.get_channel(int(rules_channel_id))
|
||||
btn_channel = discord.ui.Button(
|
||||
btn_channel = disnake.ui.Button(
|
||||
label=f"Salon: {channel.name if channel else 'Invalide'}",
|
||||
style=discord.ButtonStyle.green,
|
||||
style=disnake.ButtonStyle.green,
|
||||
custom_id="rules_channel"
|
||||
)
|
||||
else:
|
||||
btn_channel = discord.ui.Button(
|
||||
btn_channel = disnake.ui.Button(
|
||||
label="Aucun salon configuré",
|
||||
style=discord.ButtonStyle.red,
|
||||
style=disnake.ButtonStyle.red,
|
||||
custom_id="rules_channel"
|
||||
)
|
||||
btn_channel.callback = self.open_rules_channel_modal
|
||||
|
|
@ -497,15 +506,15 @@ class SecurityView(discord.ui.View):
|
|||
rules_role_id = self.settings.get("rules_accept_role_id")
|
||||
if rules_role_id:
|
||||
role = self.guild.get_role(int(rules_role_id))
|
||||
btn_role = discord.ui.Button(
|
||||
btn_role = disnake.ui.Button(
|
||||
label=f"Rôle: {role.name if role else 'Invalide'}",
|
||||
style=discord.ButtonStyle.green,
|
||||
style=disnake.ButtonStyle.green,
|
||||
custom_id="rules_role"
|
||||
)
|
||||
else:
|
||||
btn_role = discord.ui.Button(
|
||||
btn_role = disnake.ui.Button(
|
||||
label="Aucun rôle configuré",
|
||||
style=discord.ButtonStyle.red,
|
||||
style=disnake.ButtonStyle.red,
|
||||
custom_id="rules_role"
|
||||
)
|
||||
btn_role.callback = self.open_rules_role_modal
|
||||
|
|
@ -514,9 +523,9 @@ class SecurityView(discord.ui.View):
|
|||
# Bouton pour choisir le type de message
|
||||
message_type = self.settings.get("rules_message_type", "embed")
|
||||
type_label = "Embed" if message_type == "embed" else "Message simple"
|
||||
btn_message_type = discord.ui.Button(
|
||||
btn_message_type = disnake.ui.Button(
|
||||
label=f"Type: {type_label}",
|
||||
style=discord.ButtonStyle.blurple,
|
||||
style=disnake.ButtonStyle.blurple,
|
||||
emoji="📝",
|
||||
custom_id="rules_message_type"
|
||||
)
|
||||
|
|
@ -524,84 +533,84 @@ class SecurityView(discord.ui.View):
|
|||
self.add_item(btn_message_type)
|
||||
|
||||
# Bouton pour envoyer le règlement
|
||||
btn_send = discord.ui.Button(
|
||||
btn_send = disnake.ui.Button(
|
||||
label="Envoyer le règlement",
|
||||
style=discord.ButtonStyle.green,
|
||||
style=disnake.ButtonStyle.green,
|
||||
emoji="🚀"
|
||||
)
|
||||
btn_send.callback = self.send_rules
|
||||
self.add_item(btn_send)
|
||||
|
||||
# Bouton retour
|
||||
btn_back = discord.ui.Button(
|
||||
btn_back = disnake.ui.Button(
|
||||
label="Retour",
|
||||
style=discord.ButtonStyle.gray,
|
||||
style=disnake.ButtonStyle.gray,
|
||||
emoji="⬅️"
|
||||
)
|
||||
btn_back.callback = self.back_to_main
|
||||
self.add_item(btn_back)
|
||||
|
||||
async def toggle_setting(self, interaction: discord.Interaction, key: str):
|
||||
async def toggle_setting(self, interaction: disnake.Interaction, key: str):
|
||||
"""Bascule un paramètre"""
|
||||
self.settings[key] = not self.settings.get(key, False)
|
||||
save_settings(self.guild.id, self.settings)
|
||||
self.build_view()
|
||||
await interaction.response.edit_message(view=self)
|
||||
|
||||
async def open_spam_threshold_modal(self, interaction: discord.Interaction):
|
||||
async def open_spam_threshold_modal(self, interaction: disnake.Interaction):
|
||||
"""Ouvre le modal de configuration du seuil spam"""
|
||||
await interaction.response.send_modal(
|
||||
SpamThresholdModal(self.guild.id, self.settings, self)
|
||||
)
|
||||
|
||||
async def open_raid_threshold_modal(self, interaction: discord.Interaction):
|
||||
async def open_raid_threshold_modal(self, interaction: disnake.Interaction):
|
||||
"""Ouvre le modal de configuration du seuil raid"""
|
||||
await interaction.response.send_modal(
|
||||
RaidThresholdModal(self.guild.id, self.settings, self)
|
||||
)
|
||||
|
||||
async def open_whitelist_modal(self, interaction: discord.Interaction):
|
||||
async def open_whitelist_modal(self, interaction: disnake.Interaction):
|
||||
"""Ouvre le modal d'ajout à la whitelist"""
|
||||
await interaction.response.send_modal(
|
||||
WhitelistModal(self.guild.id, self)
|
||||
)
|
||||
|
||||
async def open_rules_title_modal(self, interaction: discord.Interaction):
|
||||
async def open_rules_title_modal(self, interaction: disnake.Interaction):
|
||||
"""Ouvre le modal de configuration du titre du règlement"""
|
||||
from .modules_security.rules import RulesTitleModal
|
||||
await interaction.response.send_modal(
|
||||
RulesTitleModal(self.guild.id, self.settings)
|
||||
)
|
||||
|
||||
async def open_rules_content_modal(self, interaction: discord.Interaction):
|
||||
async def open_rules_content_modal(self, interaction: disnake.Interaction):
|
||||
"""Ouvre le modal de configuration du contenu du règlement"""
|
||||
from .modules_security.rules import RulesContentModal
|
||||
await interaction.response.send_modal(
|
||||
RulesContentModal(self.guild.id, self.settings)
|
||||
)
|
||||
|
||||
async def open_rules_channel_modal(self, interaction: discord.Interaction):
|
||||
async def open_rules_channel_modal(self, interaction: disnake.Interaction):
|
||||
"""Ouvre le modal de sélection du salon du règlement"""
|
||||
from .modules_security.rules import RulesChannelModal
|
||||
await interaction.response.send_modal(
|
||||
RulesChannelModal(self.guild.id, self.settings)
|
||||
)
|
||||
|
||||
async def open_rules_role_modal(self, interaction: discord.Interaction):
|
||||
async def open_rules_role_modal(self, interaction: disnake.Interaction):
|
||||
"""Ouvre le modal de configuration du rôle d'acceptation"""
|
||||
from .modules_security.rules import RulesRoleModal
|
||||
await interaction.response.send_modal(
|
||||
RulesRoleModal(self.guild.id, self.settings)
|
||||
)
|
||||
|
||||
async def open_rules_message_type_modal(self, interaction: discord.Interaction):
|
||||
async def open_rules_message_type_modal(self, interaction: disnake.Interaction):
|
||||
"""Ouvre le modal de sélection du type de message"""
|
||||
from .modules_security.rules import RulesMessageTypeModal
|
||||
await interaction.response.send_modal(
|
||||
RulesMessageTypeModal(self.guild.id, self.settings)
|
||||
)
|
||||
|
||||
async def send_rules(self, interaction: discord.Interaction):
|
||||
async def send_rules(self, interaction: disnake.Interaction):
|
||||
"""Envoie le règlement dans le salon configuré"""
|
||||
from commandes.modules_security.rules import RulesCog
|
||||
|
||||
|
|
@ -670,16 +679,58 @@ class SecurityView(discord.ui.View):
|
|||
ephemeral=True
|
||||
)
|
||||
|
||||
async def back_to_main(self, interaction: discord.Interaction):
|
||||
def build_members_view(self):
|
||||
"""Construit la vue Paramètres Membres"""
|
||||
# Toggle Restauration des rôles
|
||||
role_restore = self.settings.get("role_restore_enabled", True)
|
||||
btn_restore = ToggleButton(
|
||||
label="Restauration des rôles",
|
||||
emoji="🔄",
|
||||
is_enabled=role_restore,
|
||||
custom_id="role_restore_enabled"
|
||||
)
|
||||
self.add_item(btn_restore)
|
||||
|
||||
# Bouton retour
|
||||
btn_back = disnake.ui.Button(
|
||||
label="Retour",
|
||||
style=disnake.ButtonStyle.gray,
|
||||
emoji="⬅️"
|
||||
)
|
||||
btn_back.callback = self.back_to_main
|
||||
self.add_item(btn_back)
|
||||
|
||||
def build_taches_view(self):
|
||||
"""Construit la vue Gestion des Tâches"""
|
||||
# Toggle Activer/Désactiver
|
||||
taches_enabled = self.settings.get("taches_enabled", True)
|
||||
btn_taches = ToggleButton(
|
||||
label="Module Tâches",
|
||||
emoji="📌",
|
||||
is_enabled=taches_enabled,
|
||||
custom_id="taches_enabled"
|
||||
)
|
||||
self.add_item(btn_taches)
|
||||
|
||||
# Bouton retour
|
||||
btn_back = disnake.ui.Button(
|
||||
label="Retour",
|
||||
style=disnake.ButtonStyle.gray,
|
||||
emoji="⬅️"
|
||||
)
|
||||
btn_back.callback = self.back_to_main
|
||||
self.add_item(btn_back)
|
||||
|
||||
async def back_to_main(self, interaction: disnake.Interaction):
|
||||
"""Retour au menu principal"""
|
||||
self.category = None
|
||||
self.build_view()
|
||||
embed = self.create_main_embed(interaction.user)
|
||||
await interaction.response.edit_message(embed=embed, view=self)
|
||||
|
||||
def create_main_embed(self, user: discord.Member = None) -> discord.Embed:
|
||||
def create_main_embed(self, user: disnake.Member = None) -> disnake.Embed:
|
||||
"""Crée l'embed du menu principal"""
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title="🛡️ Centre de Sécurité Kuby",
|
||||
description="Gérez la protection de votre serveur",
|
||||
color=0x7289DA
|
||||
|
|
@ -726,9 +777,9 @@ class SecurityView(discord.ui.View):
|
|||
|
||||
return embed
|
||||
|
||||
def create_category_embed(self, title: str, description: str) -> discord.Embed:
|
||||
def create_category_embed(self, title: str, description: str) -> disnake.Embed:
|
||||
"""Crée un embed pour une catégorie"""
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title=title,
|
||||
description=description,
|
||||
color=0x7289DA
|
||||
|
|
@ -740,11 +791,11 @@ class SecurityView(discord.ui.View):
|
|||
return embed
|
||||
|
||||
|
||||
class ToggleButton(discord.ui.Button):
|
||||
class ToggleButton(disnake.ui.Button):
|
||||
"""Bouton de bascule avec indicateur de statut"""
|
||||
|
||||
def __init__(self, label: str, emoji: str, is_enabled: bool, custom_id: str):
|
||||
style = discord.ButtonStyle.green if is_enabled else discord.ButtonStyle.red
|
||||
style = disnake.ButtonStyle.green if is_enabled else disnake.ButtonStyle.red
|
||||
status = "✅" if is_enabled else "❌"
|
||||
super().__init__(
|
||||
label=f"{label} [{status}]",
|
||||
|
|
@ -753,7 +804,7 @@ class ToggleButton(discord.ui.Button):
|
|||
emoji=emoji
|
||||
)
|
||||
|
||||
async def callback(self, interaction: discord.Interaction):
|
||||
async def callback(self, interaction: disnake.Interaction):
|
||||
view: SecurityView = self.view
|
||||
key = interaction.data['custom_id']
|
||||
|
||||
|
|
@ -771,46 +822,58 @@ class ToggleButton(discord.ui.Button):
|
|||
await interaction.response.edit_message(view=view)
|
||||
|
||||
|
||||
class MainCategorySelect(discord.ui.Select):
|
||||
class MainCategorySelect(disnake.ui.Select):
|
||||
"""Menu de sélection des catégories"""
|
||||
|
||||
def __init__(self):
|
||||
options = [
|
||||
discord.SelectOption(
|
||||
disnake.SelectOption(
|
||||
label="Anti-Spam",
|
||||
emoji="🛡️",
|
||||
value="spam",
|
||||
description="Protection contre le spam et les liens"
|
||||
),
|
||||
discord.SelectOption(
|
||||
disnake.SelectOption(
|
||||
label="Anti-Raid",
|
||||
emoji="⚔️",
|
||||
value="raid",
|
||||
description="Protection contre les raids"
|
||||
),
|
||||
discord.SelectOption(
|
||||
disnake.SelectOption(
|
||||
label="Anti-Nuke",
|
||||
emoji="☢️",
|
||||
value="nuke",
|
||||
description="Protection contre la destruction du serveur"
|
||||
),
|
||||
discord.SelectOption(
|
||||
disnake.SelectOption(
|
||||
label="Logs",
|
||||
emoji="📋",
|
||||
value="logs",
|
||||
description="Journalisation des événements"
|
||||
),
|
||||
discord.SelectOption(
|
||||
disnake.SelectOption(
|
||||
label="Règlement",
|
||||
emoji="📜",
|
||||
value="rules",
|
||||
description="Créer et envoyer un règlement"
|
||||
),
|
||||
discord.SelectOption(
|
||||
disnake.SelectOption(
|
||||
label="Whitelist",
|
||||
emoji="👥",
|
||||
value="whitelist",
|
||||
description="Gérer les utilisateurs immunisés"
|
||||
),
|
||||
disnake.SelectOption(
|
||||
label="Membres",
|
||||
emoji="👤",
|
||||
value="membres",
|
||||
description="Paramètres liés aux membres"
|
||||
),
|
||||
disnake.SelectOption(
|
||||
label="Tâches",
|
||||
emoji="📌",
|
||||
value="taches",
|
||||
description="Gestion des tâches et rappels"
|
||||
)
|
||||
]
|
||||
super().__init__(
|
||||
|
|
@ -820,7 +883,7 @@ class MainCategorySelect(discord.ui.Select):
|
|||
max_values=1
|
||||
)
|
||||
|
||||
async def callback(self, interaction: discord.Interaction):
|
||||
async def callback(self, interaction: disnake.Interaction):
|
||||
view: SecurityView = self.view
|
||||
view.category = self.values[0]
|
||||
view.build_view()
|
||||
|
|
@ -864,6 +927,18 @@ class MainCategorySelect(discord.ui.Select):
|
|||
"Gestion Whitelist",
|
||||
"Gérez les utilisateurs immunisés aux protections.\n\n"
|
||||
"Ces utilisateurs ne seront jamais sanctionnés."
|
||||
),
|
||||
"membres": view.create_category_embed(
|
||||
"Paramètres Membres",
|
||||
"Paramètres liés à la gestion des membres.\n\n"
|
||||
"• **Restauration des rôles** : Restaurer automatiquement les rôles des membres de retour"
|
||||
),
|
||||
"taches": view.create_category_embed(
|
||||
"Gestion des Tâches",
|
||||
"Configurez le système de gestion des tâches.\n\n"
|
||||
"• **Activer/Désactiver** : Active ou désactive le module sur ce serveur\n"
|
||||
"• **Création** : Seuls les administrateurs peuvent créer des tâches\n"
|
||||
"• **Rappels** : Notifications automatiques 1h avant l'échéance"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -871,10 +946,10 @@ class MainCategorySelect(discord.ui.Select):
|
|||
await interaction.response.edit_message(embed=embed, view=view)
|
||||
|
||||
|
||||
class WhitelistSelect(discord.ui.Select):
|
||||
class WhitelistSelect(disnake.ui.Select):
|
||||
"""Menu dropdown pour voir et gérer les utilisateurs whitelistés"""
|
||||
|
||||
def __init__(self, guild: discord.Guild, view: 'SecurityView'):
|
||||
def __init__(self, guild: disnake.Guild, view: 'SecurityView'):
|
||||
self.guild = guild
|
||||
self.parent_view = view
|
||||
whitelist = load_whitelist(guild.id)
|
||||
|
|
@ -890,7 +965,7 @@ class WhitelistSelect(discord.ui.Select):
|
|||
label = f"ID: {user_id} (non trouvé)"
|
||||
emoji = "❓"
|
||||
options.append(
|
||||
discord.SelectOption(
|
||||
disnake.SelectOption(
|
||||
label=label[:100], # Discord limite à 100 chars
|
||||
value=str(user_id),
|
||||
emoji=emoji,
|
||||
|
|
@ -905,7 +980,7 @@ class WhitelistSelect(discord.ui.Select):
|
|||
max_values=1
|
||||
)
|
||||
|
||||
async def callback(self, interaction: discord.Interaction):
|
||||
async def callback(self, interaction: disnake.Interaction):
|
||||
"""Gère la sélection d'un utilisateur à retirer"""
|
||||
user_id = int(self.values[0])
|
||||
|
||||
|
|
@ -951,67 +1026,64 @@ class Security(commands.Cog):
|
|||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
@app_commands.command(
|
||||
@commands.slash_command(
|
||||
name="security",
|
||||
description="🛡️ Ouvrir le panneau de sécurité Kuby"
|
||||
)
|
||||
async def security(self, interaction: discord.Interaction):
|
||||
@check_premium_tier()
|
||||
async def security(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
"""
|
||||
Commande principale pour ouvrir l'interface de sécurité.
|
||||
Accessible uniquement aux administrateurs et propriétaires whitelistés.
|
||||
"""
|
||||
# On diffère la réponse immédiatement car le chargement des réglages peut prendre du temps
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
# Vérification des permissions
|
||||
if not is_immune(interaction.guild, interaction.user):
|
||||
if interaction.response.is_done():
|
||||
return await interaction.followup.send(
|
||||
"❌ **Accès refusé**\n\nVous devez être administrateur, propriétaire du serveur, ou être dans la whitelist pour accéder à ce panneau.",
|
||||
ephemeral=True
|
||||
)
|
||||
return await interaction.response.send_message(
|
||||
return await interaction.followup.send(
|
||||
"❌ **Accès refusé**\n\nVous devez être administrateur, propriétaire du serveur, ou être dans la whitelist pour accéder à ce panneau.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
# Chargement des paramètres
|
||||
settings = load_settings(interaction.guild.id)
|
||||
try:
|
||||
# Chargement des paramètres
|
||||
settings = load_settings(interaction.guild.id)
|
||||
|
||||
# Création de la vue
|
||||
view = SecurityView(interaction, settings, bot=self.bot)
|
||||
embed = view.create_main_embed(interaction.user)
|
||||
# Création de la vue
|
||||
view = SecurityView(interaction, settings, bot=self.bot)
|
||||
embed = view.create_main_embed(interaction.user)
|
||||
|
||||
if interaction.response.is_done():
|
||||
await interaction.followup.send(
|
||||
embed=embed,
|
||||
view=view,
|
||||
ephemeral=True
|
||||
)
|
||||
else:
|
||||
await interaction.response.send_message(
|
||||
embed=embed,
|
||||
view=view,
|
||||
except Exception as e:
|
||||
await interaction.followup.send(
|
||||
f"❌ Une erreur est survenue lors de l'ouverture du panneau : {e}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
@security.error
|
||||
async def security_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError):
|
||||
async def security_error(self, interaction: disnake.Interaction, error: Exception):
|
||||
"""Gestion des erreurs de la commande security"""
|
||||
# Ne pas envoyer de message si l'interaction a déjà été traitée
|
||||
if interaction.response.is_done():
|
||||
return
|
||||
# Utilisation de followup si l'interaction est déjà différée ou répondue
|
||||
send_method = interaction.followup.send if interaction.response.is_done() else interaction.response.send_message
|
||||
|
||||
if isinstance(error, app_commands.MissingPermissions):
|
||||
await interaction.response.send_message(
|
||||
if isinstance(error, commands.MissingPermissions):
|
||||
await send_method(
|
||||
"❌ Permissions insuffisantes.",
|
||||
ephemeral=True
|
||||
)
|
||||
else:
|
||||
await interaction.response.send_message(
|
||||
f"❌ Une erreur est survenue.",
|
||||
await send_method(
|
||||
f"❌ Une erreur est survenue lors de l'exécution de la commande : {error}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
def setup(bot):
|
||||
"""Setup du cog de sécurité"""
|
||||
await bot.add_cog(Security(bot))
|
||||
bot.add_cog(Security(bot))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,170 @@
|
|||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
import disnake
|
||||
from disnake.ext import commands
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
|
||||
OWNER_ID = 971446412690722826
|
||||
|
||||
|
||||
def is_authorized(interaction: disnake.ApplicationCommandInteraction) -> bool:
|
||||
return interaction.user.id == OWNER_ID
|
||||
|
||||
|
||||
class AnnouncementModal(disnake.ui.Modal):
|
||||
def __init__(self, cog: "SentBotMessages"):
|
||||
self.cog = cog
|
||||
components = [
|
||||
disnake.ui.TextInput(
|
||||
label="Titre principal de l'annonce",
|
||||
placeholder="Ex: Mise à jour important / Maintenance / Nouveau système",
|
||||
custom_id="announcement_title",
|
||||
required=True,
|
||||
max_length=100,
|
||||
),
|
||||
disnake.ui.TextInput(
|
||||
label="Message de l'annonce (contenu réel)",
|
||||
style=disnake.TextInputStyle.paragraph,
|
||||
placeholder="Écris le vrai contenu de l'annonce ici.",
|
||||
custom_id="announcement_message",
|
||||
required=True,
|
||||
max_length=4000,
|
||||
),
|
||||
disnake.ui.TextInput(
|
||||
label="Message de prévention / contexte MP",
|
||||
style=disnake.TextInputStyle.paragraph,
|
||||
placeholder="Ex: Ce MP vous informe d'une annonce importante.",
|
||||
custom_id="announcement_prevention",
|
||||
required=True,
|
||||
max_length=1000,
|
||||
),
|
||||
]
|
||||
super().__init__(
|
||||
title="Annonce aux propriétaires",
|
||||
custom_id="announcement_modal",
|
||||
components=components,
|
||||
)
|
||||
|
||||
async def callback(self, interaction: disnake.ModalInteraction):
|
||||
title = (interaction.text_values.get("announcement_title") or "").strip()
|
||||
message = (interaction.text_values.get("announcement_message") or "").strip()
|
||||
prevention = (interaction.text_values.get("announcement_prevention") or "").strip()
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
preview_container = disnake.ui.Container(
|
||||
disnake.ui.TextDisplay(content=f"# {title}"),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.TextDisplay(content=prevention),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.TextDisplay(content=message),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.TextDisplay(
|
||||
content=(
|
||||
"📍 **Prévisualisation**\n"
|
||||
"Le même format sera envoyé aux propriétaires."
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
successful = 0
|
||||
failed_servers = []
|
||||
|
||||
for guild in self.cog.bot.guilds:
|
||||
try:
|
||||
owner = guild.owner
|
||||
if owner is None:
|
||||
owner = await guild.fetch_member(guild.owner_id)
|
||||
|
||||
container = disnake.ui.Container(
|
||||
disnake.ui.TextDisplay(content=f"# {title}"),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.TextDisplay(content=prevention),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.TextDisplay(content=message),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.TextDisplay(
|
||||
content=(
|
||||
f"📍 **Serveur** : {guild.name}\n"
|
||||
f"🆔 **ID** : `{guild.id}`"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
await owner.send(components=[container])
|
||||
successful += 1
|
||||
except Exception as e:
|
||||
failed_servers.append(f"{guild.name} ({guild.id}) : {e}")
|
||||
|
||||
result_text = (
|
||||
f"✅ Annonce envoyée à {successful} propriétaire(s)."
|
||||
if not failed_servers
|
||||
else (
|
||||
f"✅ Annonce envoyée à {successful} propriétaire(s).\n"
|
||||
f"❌ Échecs ({len(failed_servers)}):\n"
|
||||
f"- {'\n- '.join(failed_servers[:10])}"
|
||||
f"{'\n... et ' + str(len(failed_servers) - 10) + ' autres' if len(failed_servers) > 10 else ''}"
|
||||
)
|
||||
)
|
||||
|
||||
result_container = disnake.ui.Container(
|
||||
disnake.ui.TextDisplay(content=result_text),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.TextDisplay(content="🧩 Aperçu du format envoyé aux propriétaires :"),
|
||||
disnake.ui.Separator(divider=True),
|
||||
*preview_container.children
|
||||
)
|
||||
|
||||
await interaction.edit_original_response(components=[result_container])
|
||||
|
||||
|
||||
class SentBotMessages(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
@app_commands.command(name="sentbotmessages", description="Le bot envoie un message dans un salon choisi.")
|
||||
@app_commands.describe(
|
||||
message="Le message à envoyer",
|
||||
salon="Le salon où envoyer le message"
|
||||
@commands.slash_command(
|
||||
name="sentbotmessages",
|
||||
description="Le bot envoie un message dans un salon choisi."
|
||||
)
|
||||
async def sentbotmessages(self, interaction: discord.Interaction, message: str, salon: discord.TextChannel):
|
||||
@check_premium_tier()
|
||||
async def sentbotmessages(
|
||||
self,
|
||||
interaction: disnake.ApplicationCommandInteraction,
|
||||
message: str = commands.Param(description="Le message à envoyer"),
|
||||
salon: disnake.TextChannel = commands.Param(description="Le salon où envoyer le message")
|
||||
):
|
||||
# Vérifie si l'utilisateur est admin
|
||||
if not interaction.user.guild_permissions.administrator:
|
||||
await interaction.response.send_message("⛔ Tu n'as pas la permission d'utiliser cette commande.", ephemeral=True)
|
||||
await interaction.response.send_message(
|
||||
"⛔ Tu n'as pas la permission d'utiliser cette commande.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await salon.send(message)
|
||||
await interaction.response.send_message(f"✅ Message envoyé dans {salon.mention}.", ephemeral=True)
|
||||
await interaction.response.send_message(
|
||||
f"✅ Message envoyé dans {salon.mention}.",
|
||||
ephemeral=True
|
||||
)
|
||||
except Exception as e:
|
||||
await interaction.response.send_message(f"❌ Une erreur est survenue : {e}", ephemeral=True)
|
||||
await interaction.response.send_message(
|
||||
f"❌ Une erreur est survenue : {e}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(SentBotMessages(bot))
|
||||
@commands.slash_command(
|
||||
name="annonce_proprietaires",
|
||||
description="Ouvrir le formulaire d'annonce pour les propriétaires."
|
||||
)
|
||||
@check_premium_tier()
|
||||
@commands.check(is_authorized)
|
||||
async def annonce_proprietaires(
|
||||
self,
|
||||
interaction: disnake.ApplicationCommandInteraction,
|
||||
):
|
||||
await interaction.response.send_modal(AnnouncementModal(self))
|
||||
|
||||
|
||||
def setup(bot):
|
||||
bot.add_cog(SentBotMessages(bot))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
import disnake
|
||||
from disnake.ext import commands
|
||||
from datetime import datetime
|
||||
from collections import deque
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
class SnipeCommands(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
|
|
@ -34,9 +34,10 @@ class SnipeCommands(commands.Cog):
|
|||
|
||||
self.deleted_messages[channel_id].appendleft(message_data)
|
||||
|
||||
@app_commands.command(name="snipe", description="Voir les derniers messages supprimés d'un salon")
|
||||
@app_commands.describe(nombre="Nombre de messages à afficher (1-10, par défaut: 1)")
|
||||
async def snipe(self, interaction: discord.Interaction, nombre: int = 1):
|
||||
@commands.slash_command(name="snipe", description="Voir les derniers messages supprimés d'un salon")
|
||||
@check_premium_tier()
|
||||
async def snipe(self, interaction: disnake.ApplicationCommandInteraction,
|
||||
nombre: int = commands.Param(1, description="Nombre de messages à afficher (1-10, par défaut: 1)")):
|
||||
"""Retrieve deleted messages from the current channel"""
|
||||
channel_id = interaction.channel.id
|
||||
|
||||
|
|
@ -60,9 +61,9 @@ class SnipeCommands(commands.Cog):
|
|||
messages = list(self.deleted_messages[channel_id])[:nombre]
|
||||
|
||||
# Create embed for displaying deleted messages
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title=f"🗑️ Messages supprimés ({len(messages)} sur {len(self.deleted_messages[channel_id])} disponibles)",
|
||||
color=discord.Color.red()
|
||||
color=disnake.Color.red()
|
||||
)
|
||||
|
||||
for i, msg_data in enumerate(messages, 1):
|
||||
|
|
@ -93,8 +94,9 @@ class SnipeCommands(commands.Cog):
|
|||
|
||||
await interaction.response.send_message(embed=embed)
|
||||
|
||||
@app_commands.command(name="snipeclear", description="Effacer l'historique des messages supprimés pour ce salon")
|
||||
async def snipeclear(self, interaction: discord.Interaction):
|
||||
@commands.slash_command(name="snipeclear", description="Effacer l'historique des messages supprimés pour ce salon")
|
||||
@check_premium_tier()
|
||||
async def snipeclear(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
"""Clear the deleted messages history for the current channel"""
|
||||
channel_id = interaction.channel.id
|
||||
|
||||
|
|
@ -110,5 +112,5 @@ class SnipeCommands(commands.Cog):
|
|||
ephemeral=True
|
||||
)
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(SnipeCommands(bot))
|
||||
def setup(bot):
|
||||
bot.add_cog(SnipeCommands(bot))
|
||||
|
|
|
|||
74
commandes/staff_leaderboard.py
Normal file
74
commandes/staff_leaderboard.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import disnake
|
||||
from disnake.ext import commands
|
||||
from datetime import datetime
|
||||
|
||||
from commandes.ticket.data.storage import get_storage
|
||||
from commandes.ticket.staff_analytics import StaffAnalytics
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
|
||||
class StaffLeaderboard(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
@commands.slash_command(name="staff-leaderboard", description="Affiche le classement du staff")
|
||||
@check_premium_tier()
|
||||
async def staff_leaderboard(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
"""Commande pour afficher le classement du staff"""
|
||||
await interaction.response.defer(ephemeral=False)
|
||||
|
||||
try:
|
||||
storage = get_storage()
|
||||
analytics = StaffAnalytics(self.bot)
|
||||
|
||||
# Get staff list
|
||||
staff_list = await analytics.get_staff_list(interaction.guild_id)
|
||||
|
||||
if not staff_list:
|
||||
await interaction.followup.send("Aucun membre du staff n'a été trouvé.", ephemeral=True)
|
||||
return
|
||||
|
||||
# Sort by average rating (descending)
|
||||
staff_list.sort(key=lambda x: x['average_rating'], reverse=True)
|
||||
|
||||
# Create embed
|
||||
embed = disnake.Embed(
|
||||
title="🏆 Classement du Staff",
|
||||
description="Classement des meilleurs membres du staff",
|
||||
color=disnake.Color.gold(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
# Add top 10 staff members
|
||||
top_staff = staff_list[:10]
|
||||
for i, staff in enumerate(top_staff, 1):
|
||||
rating = staff['average_rating']
|
||||
count = staff['total_ratings']
|
||||
name = staff['name']
|
||||
|
||||
# Add emoji based on rating
|
||||
if rating >= 4.5:
|
||||
emoji = "🥇"
|
||||
elif rating >= 4.0:
|
||||
emoji = "🥈"
|
||||
elif rating >= 3.5:
|
||||
emoji = "🥉"
|
||||
else:
|
||||
emoji = "⭐"
|
||||
|
||||
embed.add_field(
|
||||
name=f"{emoji} #{i} {name}",
|
||||
value=f"Note: {rating:.1f}⭐ ({count} évaluations)",
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Send embed
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in staff_leaderboard command: {e}")
|
||||
await interaction.followup.send("Une erreur est survenue lors de la génération du classement.", ephemeral=True)
|
||||
|
||||
|
||||
def setup(bot):
|
||||
bot.add_cog(StaffLeaderboard(bot))
|
||||
139
commandes/staff_ratings.py
Normal file
139
commandes/staff_ratings.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import disnake
|
||||
from disnake.ext import commands
|
||||
import matplotlib.pyplot as plt
|
||||
import plotly.graph_objects as go
|
||||
import plotly.express as px
|
||||
from plotly.subplots import make_subplots
|
||||
import io
|
||||
import numpy as np
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
from commandes.ticket.data.storage import get_storage
|
||||
from commandes.ticket.data.models import TicketStatus
|
||||
from commandes.ticket.staff_analytics import StaffAnalytics
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
|
||||
class StaffRatings(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
@commands.slash_command(name="staff-ratings", description="Affiche les évaluations du staff")
|
||||
@check_premium_tier()
|
||||
async def staff_ratings(self, interaction: disnake.ApplicationCommandInteraction,
|
||||
team: str = commands.Param(None, description="Filtrer par équipe (optionnel)")):
|
||||
"""Commande pour afficher les évaluations du staff"""
|
||||
await interaction.response.defer(ephemeral=False)
|
||||
|
||||
try:
|
||||
storage = get_storage()
|
||||
analytics = StaffAnalytics(self.bot)
|
||||
|
||||
# Get staff list
|
||||
staff_list = await analytics.get_staff_list(interaction.guild_id)
|
||||
|
||||
if not staff_list:
|
||||
await interaction.followup.send("Aucun membre du staff n'a été trouvé.", ephemeral=True)
|
||||
return
|
||||
|
||||
# Filter by team if specified
|
||||
if team:
|
||||
# This would require a team mapping in the staff data
|
||||
# For now, we'll show all staff
|
||||
pass
|
||||
|
||||
# Create the rating chart using matplotlib
|
||||
chart_buffer = self.create_staff_rating_chart_matplotlib(staff_list)
|
||||
|
||||
# Create embed
|
||||
embed = disnake.Embed(
|
||||
title="📊 Évaluations du Staff",
|
||||
description="Performance globale du staff",
|
||||
color=disnake.Color.blue(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
# Add stats
|
||||
total_ratings = sum(s['total_ratings'] for s in staff_list)
|
||||
avg_rating = sum(s['average_rating'] for s in staff_list if s['total_ratings'] > 0) / len([s for s in staff_list if s['total_ratings'] > 0]) if any(s['total_ratings'] > 0 for s in staff_list) else 0
|
||||
|
||||
embed.add_field(
|
||||
name="📈 Statistiques",
|
||||
value=f"**Membres du staff:** {len(staff_list)}\n"
|
||||
f"**Évaluations totales:** {total_ratings}\n"
|
||||
f"**Note moyenne globale:** {avg_rating:.1f}⭐",
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Send chart
|
||||
file = disnake.File(chart_buffer, filename="staff_ratings.png")
|
||||
await interaction.followup.send(embed=embed, file=file)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in staff_ratings command: {e}")
|
||||
await interaction.followup.send("Une erreur est survenue lors de la génération des statistiques.", ephemeral=True)
|
||||
|
||||
def create_staff_rating_chart_matplotlib(self, staff_list):
|
||||
"""Create staff rating chart using matplotlib"""
|
||||
# Filter staff with ratings
|
||||
rated_staff = [s for s in staff_list if s['total_ratings'] > 0]
|
||||
|
||||
if not rated_staff:
|
||||
# Return empty chart
|
||||
fig, ax = plt.subplots(figsize=(10, 6), facecolor='#2F3136')
|
||||
ax.set_facecolor('#2F3136')
|
||||
ax.text(0.5, 0.5, 'Aucune évaluation disponible', ha='center', va='center',
|
||||
transform=ax.transAxes, color='white')
|
||||
ax.set_title('Évaluations des Membres du Staff', color='white')
|
||||
buf = io.BytesIO()
|
||||
plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136')
|
||||
buf.seek(0)
|
||||
plt.close()
|
||||
return buf
|
||||
|
||||
# Sort by average rating
|
||||
rated_staff.sort(key=lambda x: x['average_rating'], reverse=True)
|
||||
|
||||
# Prepare data
|
||||
names = [s['name'][:15] + '...' if len(s['name']) > 15 else s['name'] for s in rated_staff]
|
||||
ratings = [s['average_rating'] for s in rated_staff]
|
||||
counts = [s['total_ratings'] for s in rated_staff]
|
||||
|
||||
# Create figure
|
||||
fig, ax = plt.subplots(figsize=(12, 8), facecolor='#2F3136')
|
||||
fig.patch.set_facecolor('#2F3136')
|
||||
ax.set_facecolor('#2F3136')
|
||||
|
||||
# Create bars
|
||||
bars = ax.bar(range(len(ratings)), ratings, color=['#57F287' if r >= 4.5 else '#FEE75C' if r >= 3.5 else '#FAA61A' if r >= 2.5 else '#ED4245' for r in ratings])
|
||||
|
||||
# Customize
|
||||
ax.set_xticks(range(len(names)))
|
||||
ax.set_xticklabels(names, rotation=45, ha='right', color='white')
|
||||
ax.set_ylabel('Note moyenne', color='white')
|
||||
ax.set_title('Évaluations des Membres du Staff', color='white', fontsize=16)
|
||||
ax.tick_params(colors='white')
|
||||
ax.grid(True, alpha=0.3, color='gray')
|
||||
|
||||
# Add value labels on bars
|
||||
for i, (bar, rating, count) in enumerate(zip(bars, ratings, counts)):
|
||||
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.05,
|
||||
f'{rating:.1f}⭐\n({count})', ha='center', va='bottom', color='white', fontsize=8)
|
||||
|
||||
# Set y-axis limits
|
||||
ax.set_ylim(0, 5.2)
|
||||
|
||||
# Adjust layout
|
||||
plt.tight_layout()
|
||||
|
||||
# Save to buffer
|
||||
buf = io.BytesIO()
|
||||
plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136')
|
||||
buf.seek(0)
|
||||
plt.close()
|
||||
return buf
|
||||
|
||||
|
||||
def setup(bot):
|
||||
bot.add_cog(StaffRatings(bot))
|
||||
719
commandes/taches.py
Normal file
719
commandes/taches.py
Normal file
|
|
@ -0,0 +1,719 @@
|
|||
"""
|
||||
Cog Tâches - Système de gestion des tâches modulaire pour Kuby
|
||||
Utilise disnake >= 2.11 avec Components V2
|
||||
"""
|
||||
|
||||
import disnake
|
||||
import re
|
||||
from disnake.ext import commands, tasks
|
||||
from disnake import ui
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from typing import Optional, Dict, List, Any
|
||||
|
||||
from utils.gestion_taches import (
|
||||
ajouter_tache, lister_taches_utilisateur, lister_toutes_taches, get_tache,
|
||||
update_tache_status, update_tache_priorite, supprimer_tache,
|
||||
taches_proches, get_taches_settings, set_taches_settings,
|
||||
PRIORITES, STATUTS
|
||||
)
|
||||
from src.logger import kuby_logger
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MODALS
|
||||
# ============================================================================
|
||||
|
||||
class NouvelleTacheModal(ui.Modal):
|
||||
"""Modal pour créer une nouvelle tâche."""
|
||||
|
||||
def __init__(self, guild_id: int):
|
||||
self.guild_id = guild_id
|
||||
|
||||
# Create components list - Modals can only contain TextInput components
|
||||
components = [
|
||||
ui.TextInput(
|
||||
label="Titre",
|
||||
custom_id="tache_titre",
|
||||
placeholder="Ex: Corriger le bug du login",
|
||||
max_length=100,
|
||||
required=True
|
||||
),
|
||||
ui.TextInput(
|
||||
label="Description",
|
||||
custom_id="tache_description",
|
||||
style=disnake.TextInputStyle.paragraph,
|
||||
placeholder="Décrivez la tâche en détail...",
|
||||
max_length=1000,
|
||||
required=True
|
||||
),
|
||||
ui.TextInput(
|
||||
label="ID de l'utilisateur assigné (ou mention)",
|
||||
custom_id="tache_assigne",
|
||||
placeholder="Ex: 123456789 ou @utilisateur",
|
||||
max_length=100,
|
||||
required=True
|
||||
),
|
||||
ui.TextInput(
|
||||
label="Date d'échéance (JJ/MM/AAAA)",
|
||||
custom_id="tache_echeance",
|
||||
placeholder="31/12/2026",
|
||||
required=True
|
||||
),
|
||||
ui.TextInput(
|
||||
label="Priorité",
|
||||
custom_id="tache_priorite",
|
||||
placeholder=f"Une de: {', '.join(PRIORITES)}",
|
||||
max_length=20,
|
||||
required=False
|
||||
)
|
||||
]
|
||||
|
||||
super().__init__(title="📌 Nouvelle Tâche", custom_id="nouvelle_tache_modal", components=components)
|
||||
|
||||
async def callback(self, inter: disnake.ModalInteraction) -> None:
|
||||
"""Callback quand le modal est soumis."""
|
||||
try:
|
||||
titre = inter.text_values["tache_titre"]
|
||||
description = inter.text_values["tache_description"]
|
||||
assigne_input = inter.text_values["tache_assigne"]
|
||||
echeance_str = inter.text_values["tache_echeance"]
|
||||
priorite = inter.text_values.get("tache_priorite", "Moyenne")
|
||||
|
||||
# Parse assigne_id from input (could be ID or mention)
|
||||
mention_match = re.match(r'<@(\d+)>', assigne_input)
|
||||
if mention_match:
|
||||
assigne_id = int(mention_match.group(1))
|
||||
else:
|
||||
try:
|
||||
assigne_id = int(assigne_input)
|
||||
except ValueError:
|
||||
await inter.response.send_message(
|
||||
"❌ ID utilisateur invalide. Veuillez entrer un ID numérique ou une mention valide.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
# Validate priority
|
||||
if priorite and priorite not in PRIORITES:
|
||||
priorite = "Moyenne"
|
||||
|
||||
try:
|
||||
echeance_date = datetime.strptime(echeance_str, "%d/%m/%Y")
|
||||
echeance = echeance_date.strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
return await inter.response.send_message(
|
||||
"❌ Format de date invalide. Utilisez **JJ/MM/AAAA**.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
tache_id = await ajouter_tache(
|
||||
titre=titre,
|
||||
description=description,
|
||||
assigne_id=assigne_id,
|
||||
priorite=priorite,
|
||||
echeance=echeance,
|
||||
cree_par_id=inter.author.id,
|
||||
serveur_id=self.guild_id
|
||||
)
|
||||
|
||||
await inter.response.send_message(
|
||||
f"✅ **Tâche #{tache_id} créée avec succès !**\n"
|
||||
f"→ Assignée à : <@{assigne_id}>\n"
|
||||
f"→ Échéance : {echeance_str}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
try:
|
||||
assigne = inter.bot.get_user(assigne_id) or await inter.bot.fetch_user(assigne_id)
|
||||
if assigne:
|
||||
await assigne.send(
|
||||
f"📌 **Nouvelle tâche assignée** par {inter.author.mention} :\n\n"
|
||||
f"**#{tache_id} - {titre}**\n"
|
||||
f"📅 Échéance : {echeance_str}\n"
|
||||
f"⚠️ Priorité : {priorite}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Erreur création tâche: {e}")
|
||||
await inter.response.send_message(
|
||||
"❌ Une erreur est survenue. Veuillez réessayer.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
class ConfirmDeleteModal(ui.Modal):
|
||||
"""Modal de confirmation pour la suppression."""
|
||||
|
||||
def __init__(self, tache_id: str, tache_titre: str):
|
||||
self.tache_id = tache_id
|
||||
self.tache_titre = tache_titre
|
||||
|
||||
components = [
|
||||
ui.TextInput(
|
||||
label=f'Pour confirmer, tapez : "{tache_titre}"',
|
||||
custom_id="confirm_text",
|
||||
placeholder=f"Tapez exactement : {tache_titre}",
|
||||
required=True
|
||||
)
|
||||
]
|
||||
|
||||
super().__init__(title=f"⚠️ Supprimer la tâche #{tache_id}", custom_id=f"confirm_delete_{tache_id}", components=components)
|
||||
|
||||
async def callback(self, inter: disnake.ModalInteraction) -> None:
|
||||
confirmation = inter.text_values["confirm_text"]
|
||||
|
||||
if confirmation != self.tache_titre:
|
||||
return await inter.response.send_message(
|
||||
"❌ Confirmation incorrecte. La tâche n'a pas été supprimée.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
success = await supprimer_tache(self.tache_id)
|
||||
if success:
|
||||
await inter.response.send_message(
|
||||
f"🗑️ **Tâche #{self.tache_id} supprimée avec succès.**",
|
||||
ephemeral=True
|
||||
)
|
||||
else:
|
||||
await inter.response.send_message(
|
||||
"❌ Impossible de supprimer la tâche.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# COMPONENTS V2 HELPER FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
def _get_statut_emoji(statut: str) -> str:
|
||||
return {"À faire": "📌", "En cours": "⏳", "Terminée": "✅", "Annulée": "❌"}.get(statut, "📌")
|
||||
|
||||
|
||||
def _get_priorite_emoji(priorite: str) -> str:
|
||||
return {"Critique": "🔴", "Haute": "🟠", "Moyenne": "🟡", "Basse": "🟢", "Faible": "⚪"}.get(priorite, "🟡")
|
||||
|
||||
|
||||
def get_main_menu_components(guild_id: int) -> list:
|
||||
"""Menu principal des tâches"""
|
||||
return [
|
||||
ui.Container(
|
||||
ui.TextDisplay("📝 **Gestion des Tâches**\nUtilisez les boutons ci-dessous pour interagir."),
|
||||
ui.Section("➕ Nouvelle Tâche", accessory=ui.Button(label="Créer", style=disnake.ButtonStyle.green, custom_id=f"taches_nouvelle:{guild_id}")),
|
||||
ui.Section("📋 Mes Tâches", accessory=ui.Button(label="Mes tâches", style=disnake.ButtonStyle.blurple, custom_id=f"taches_mes:{guild_id}")),
|
||||
ui.Section("🌐 Toutes les Tâches", accessory=ui.Button(label="Toutes", style=disnake.ButtonStyle.blurple, custom_id=f"taches_toutes:{guild_id}")),
|
||||
ui.Section("🔄 Rafraîchir", accessory=ui.Button(label="Rafraîchir", style=disnake.ButtonStyle.gray, custom_id=f"taches_rafraichir:{guild_id}")),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def get_no_permission_components(guild_id: int) -> list:
|
||||
"""Message de permission refusée"""
|
||||
return [
|
||||
ui.Container(
|
||||
ui.TextDisplay("❌ **Permission refusée**\nSeuls les administrateurs peuvent utiliser cette fonctionnalité."),
|
||||
ui.Section("⬅️ Retour", accessory=ui.Button(label="Retour", style=disnake.ButtonStyle.gray, custom_id=f"taches_back:{guild_id}")),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def get_module_disabled_components(guild_id: int) -> list:
|
||||
"""Message de module désactivé"""
|
||||
return [
|
||||
ui.Container(
|
||||
ui.TextDisplay("❌ **Module désactivé**\nLe système de tâches est désactivé sur ce serveur."),
|
||||
ui.Section("⬅️ Retour", accessory=ui.Button(label="Retour", style=disnake.ButtonStyle.gray, custom_id=f"taches_back:{guild_id}")),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def get_no_taches_components(user_id: int, guild_id: int, is_all: bool = False) -> list:
|
||||
"""Message lorsqu'il n'y a pas de tâches"""
|
||||
message = "📭 **Aucune tâche assignée**\nVous n'avez aucune tâche en cours sur ce serveur."
|
||||
if is_all:
|
||||
message = "📭 **Aucune tâche**\nAucune tâche trouvée sur ce serveur."
|
||||
|
||||
return [
|
||||
ui.Container(
|
||||
ui.TextDisplay(message),
|
||||
ui.Section("⬅️ Retour au menu", accessory=ui.Button(label="Retour", style=disnake.ButtonStyle.gray, custom_id=f"taches_back_to_menu:{user_id}:{guild_id}")),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def get_taches_pagination_components(user_id: int, guild_id: int, taches_list: List[tuple], page: int, is_all: bool) -> list:
|
||||
"""Pagination des tâches - divisé en plusieurs Containers pour respecter la limite de 5 éléments"""
|
||||
start = page * 4
|
||||
end = start + 4
|
||||
page_taches = taches_list[start:end]
|
||||
total_pages = max(1, (len(taches_list) - 1) // 4 + 1)
|
||||
|
||||
containers = []
|
||||
|
||||
# Premier Container: Titre + tâches (max 4)
|
||||
container1_sections = [
|
||||
ui.TextDisplay(f"📝 **{'Toutes les tâches du serveur' if is_all else 'Vos tâches'}** :")
|
||||
]
|
||||
|
||||
if page_taches:
|
||||
for tid, tache in page_taches:
|
||||
container1_sections.append(ui.Section(
|
||||
f"#{tid} - {tache['titre'][:30]}",
|
||||
accessory=ui.Button(
|
||||
label="Voir",
|
||||
style=disnake.ButtonStyle.blurple,
|
||||
custom_id=f"tache_detail:{tid}:{user_id}:{guild_id}:{page}:{1 if is_all else 0}"
|
||||
)
|
||||
))
|
||||
else:
|
||||
container1_sections.append(ui.TextDisplay("Aucune tâche à afficher."))
|
||||
|
||||
containers.append(ui.Container(*container1_sections))
|
||||
|
||||
# Deuxième Container: Navigation
|
||||
prev_disabled = page == 0
|
||||
next_disabled = end >= len(taches_list)
|
||||
|
||||
container2_sections = []
|
||||
|
||||
if prev_disabled and next_disabled:
|
||||
container2_sections.append(ui.Section(
|
||||
"⬅️ Retour au menu",
|
||||
accessory=ui.Button(
|
||||
label="Retour",
|
||||
style=disnake.ButtonStyle.gray,
|
||||
custom_id=f"taches_back_to_menu:{user_id}:{guild_id}"
|
||||
)
|
||||
))
|
||||
else:
|
||||
if not prev_disabled:
|
||||
container2_sections.append(ui.Section(
|
||||
"⬅️ Précédent",
|
||||
accessory=ui.Button(
|
||||
label="Précédent",
|
||||
style=disnake.ButtonStyle.secondary,
|
||||
custom_id=f"taches_prev:{user_id}:{guild_id}:{page}:{1 if is_all else 0}",
|
||||
disabled=prev_disabled
|
||||
)
|
||||
))
|
||||
|
||||
container2_sections.append(ui.TextDisplay(f"Page {page + 1}/{total_pages}"))
|
||||
|
||||
if not next_disabled:
|
||||
container2_sections.append(ui.Section(
|
||||
"Suivant ➡️",
|
||||
accessory=ui.Button(
|
||||
label="Suivant",
|
||||
style=disnake.ButtonStyle.secondary,
|
||||
custom_id=f"taches_next:{user_id}:{guild_id}:{page}:{1 if is_all else 0}",
|
||||
disabled=next_disabled
|
||||
)
|
||||
))
|
||||
|
||||
if len(container2_sections) >= 5:
|
||||
containers.append(ui.Container(*container2_sections))
|
||||
container2_sections = []
|
||||
|
||||
container2_sections.append(ui.Section(
|
||||
"⬅️ Retour au menu",
|
||||
accessory=ui.Button(
|
||||
label="Retour",
|
||||
style=disnake.ButtonStyle.gray,
|
||||
custom_id=f"taches_back_to_menu:{user_id}:{guild_id}"
|
||||
)
|
||||
))
|
||||
|
||||
containers.append(ui.Container(*container2_sections))
|
||||
|
||||
return containers
|
||||
|
||||
|
||||
def get_tache_detail_components(tache: Dict[str, Any], tache_id: str, user_id: int, guild_id: int, can_edit: bool) -> list:
|
||||
"""Détails d'une tâche"""
|
||||
statut_emoji = _get_statut_emoji(tache.get("statut", "À faire"))
|
||||
priorite_emoji = _get_priorite_emoji(tache.get("priorite", "Moyenne"))
|
||||
|
||||
tache_text = (
|
||||
f"{statut_emoji} **Tâche #{tache_id}**\n\n"
|
||||
f"**Titre** : {tache.get('titre', 'N/A')}\n"
|
||||
f"**Description** : {tache.get('description', 'N/A')}\n"
|
||||
f"**Assigné à** : <@{tache.get('assigne', '0')}>\n"
|
||||
f"**Créé par** : <@{tache.get('cree_par', '0')}>\n"
|
||||
f"**Priorité** : {priorite_emoji} {tache.get('priorite', 'Moyenne')}\n"
|
||||
f"**Statut** : {tache.get('statut', 'À faire')}\n"
|
||||
f"**Échéance** : {tache.get('echeance', 'N/A')}\n"
|
||||
f"**Créée le** : {tache.get('date_creation', 'N/A')}"
|
||||
)
|
||||
|
||||
sections = [ui.TextDisplay(tache_text)]
|
||||
|
||||
if can_edit:
|
||||
sections.append(ui.Section("✅ Marquer terminée", accessory=ui.Button(label="Terminée", style=disnake.ButtonStyle.green, custom_id=f"tache_done:{tache_id}:{guild_id}")))
|
||||
sections.append(ui.Section("⚡ Changer priorité", accessory=ui.Button(label="Priorité", style=disnake.ButtonStyle.blurple, custom_id=f"tache_prio:{tache_id}:{guild_id}")))
|
||||
sections.append(ui.Section("🗑️ Supprimer", accessory=ui.Button(label="Supprimer", style=disnake.ButtonStyle.red, custom_id=f"tache_delete:{tache_id}:{guild_id}")))
|
||||
|
||||
sections.append(ui.Section("⬅️ Retour", accessory=ui.Button(label="Retour", style=disnake.ButtonStyle.gray, custom_id=f"tache_back:{guild_id}")))
|
||||
|
||||
return [ui.Container(*sections)]
|
||||
|
||||
|
||||
def get_tache_done_components(tache: Dict[str, Any], user_id: int, guild_id: int) -> list:
|
||||
"""Confirmation que la tâche est terminée (avec titre barré)"""
|
||||
priorite_emoji = _get_priorite_emoji(tache.get("priorite", "Moyenne"))
|
||||
|
||||
text = (
|
||||
f"✅ **Tâche terminée !**\n\n"
|
||||
f"📌 **Titre** : ~~{tache.get('titre', 'Inconnu')}~~\n"
|
||||
f"📅 Échéance : {tache.get('echeance', 'N/A')}\n"
|
||||
f"⚠️ Priorité : {tache.get('priorite', 'Moyenne')}\n"
|
||||
f"📝 Statut : **Terminée**"
|
||||
)
|
||||
|
||||
return [
|
||||
ui.Container(
|
||||
ui.TextDisplay(text),
|
||||
# user_id est passé dans le custom_id pour que le handler puisse vérifier l'autorisation
|
||||
ui.Section("⬅️ Retour", accessory=ui.Button(label="Retour", style=disnake.ButtonStyle.gray, custom_id=f"taches_back_to_menu:{user_id}:{guild_id}")),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def get_change_priority_components(tache_id: str, current_prio: str, user_id: int, guild_id: int) -> list:
|
||||
"""Sélection de la priorité - divisé en plusieurs Containers pour respecter la limite de 5 éléments"""
|
||||
containers = []
|
||||
|
||||
container1_sections = [
|
||||
ui.TextDisplay("📝 Sélectionnez la nouvelle priorité :")
|
||||
]
|
||||
|
||||
for prio in PRIORITES:
|
||||
style = disnake.ButtonStyle.green if prio == current_prio else disnake.ButtonStyle.secondary
|
||||
emoji = _get_priorite_emoji(prio)
|
||||
container1_sections.append(ui.Section(
|
||||
f"{emoji} {prio}",
|
||||
accessory=ui.Button(label=prio, style=style, custom_id=f"prio_select:{tache_id}:{prio}:{user_id}:{guild_id}")
|
||||
))
|
||||
|
||||
if len(container1_sections) >= 5:
|
||||
containers.append(ui.Container(*container1_sections))
|
||||
container1_sections = []
|
||||
|
||||
if container1_sections:
|
||||
containers.append(ui.Container(*container1_sections))
|
||||
|
||||
containers.append(ui.Container(
|
||||
ui.Section(
|
||||
"❌ Annuler",
|
||||
accessory=ui.Button(label="Annuler", style=disnake.ButtonStyle.red, custom_id=f"prio_cancel:{tache_id}:{user_id}:{guild_id}")
|
||||
)
|
||||
))
|
||||
|
||||
return containers
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# COG PRINCIPAL
|
||||
# ============================================================================
|
||||
|
||||
class TachesCog(commands.Cog):
|
||||
"""Cog principal pour la gestion des tâches."""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
async def cog_load(self):
|
||||
"""Démarre la tâche de rappel une fois le cog chargé."""
|
||||
self.rappels_loop.start()
|
||||
|
||||
def cog_unload(self):
|
||||
self.rappels_loop.cancel()
|
||||
|
||||
# ==========================================================================
|
||||
# HANDLER MANUEL DES INTERACTIONS DE BOUTONS
|
||||
# ==========================================================================
|
||||
|
||||
@commands.Cog.listener("on_message_interaction")
|
||||
async def on_taches_interaction(self, interaction: disnake.MessageInteraction):
|
||||
"""Gère toutes les interactions de boutons pour le système de tâches."""
|
||||
custom_id = interaction.data.custom_id
|
||||
if not custom_id or not (custom_id.startswith("taches_") or custom_id.startswith("tache_") or custom_id.startswith("prio_")):
|
||||
return
|
||||
|
||||
kuby_logger.debug(f"Interaction tâches reçue : {custom_id} par {interaction.user}")
|
||||
|
||||
try:
|
||||
parts = custom_id.split(":")
|
||||
action = parts[0]
|
||||
|
||||
# Menu principal
|
||||
if action == "taches_nouvelle":
|
||||
guild_id = int(parts[1])
|
||||
if not interaction.author.guild_permissions.administrator:
|
||||
await interaction.response.edit_message(
|
||||
components=get_no_permission_components(guild_id))
|
||||
return
|
||||
modal = NouvelleTacheModal(guild_id)
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
elif action == "taches_mes":
|
||||
guild_id = int(parts[1])
|
||||
user_id = interaction.author.id
|
||||
settings = await get_taches_settings(guild_id)
|
||||
if not settings.get("taches_enabled", True):
|
||||
await interaction.response.edit_message(
|
||||
components=get_module_disabled_components(guild_id))
|
||||
return
|
||||
taches = await lister_taches_utilisateur(user_id, guild_id)
|
||||
if not taches:
|
||||
await interaction.response.edit_message(
|
||||
components=get_no_taches_components(user_id, guild_id, is_all=False))
|
||||
return
|
||||
taches_list = list(taches.items())
|
||||
await interaction.response.edit_message(
|
||||
components=get_taches_pagination_components(user_id, guild_id, taches_list, 0, False))
|
||||
|
||||
elif action == "taches_toutes":
|
||||
guild_id = int(parts[1])
|
||||
user_id = interaction.author.id
|
||||
if not interaction.author.guild_permissions.administrator:
|
||||
await interaction.response.edit_message(
|
||||
components=get_no_permission_components(guild_id))
|
||||
return
|
||||
settings = await get_taches_settings(guild_id)
|
||||
if not settings.get("taches_enabled", True):
|
||||
await interaction.response.edit_message(
|
||||
components=get_module_disabled_components(guild_id))
|
||||
return
|
||||
taches = await lister_toutes_taches(guild_id)
|
||||
if not taches:
|
||||
await interaction.response.edit_message(
|
||||
components=get_no_taches_components(user_id, guild_id, is_all=True))
|
||||
return
|
||||
taches_list = list(taches.items())
|
||||
await interaction.response.edit_message(
|
||||
components=get_taches_pagination_components(user_id, guild_id, taches_list, 0, True))
|
||||
|
||||
elif action == "taches_rafraichir":
|
||||
guild_id = int(parts[1])
|
||||
await interaction.response.edit_message(
|
||||
components=get_main_menu_components(guild_id))
|
||||
|
||||
# Navigation
|
||||
elif action == "taches_prev":
|
||||
user_id = int(parts[1])
|
||||
guild_id = int(parts[2])
|
||||
current_page = int(parts[3])
|
||||
is_all = bool(int(parts[4]))
|
||||
if interaction.author.id != user_id:
|
||||
return
|
||||
if current_page > 0:
|
||||
new_page = current_page - 1
|
||||
taches = await lister_toutes_taches(guild_id) if is_all else await lister_taches_utilisateur(user_id, guild_id)
|
||||
taches_list = list(taches.items()) if taches else []
|
||||
await interaction.response.edit_message(
|
||||
components=get_taches_pagination_components(user_id, guild_id, taches_list, new_page, is_all))
|
||||
|
||||
elif action == "taches_next":
|
||||
user_id = int(parts[1])
|
||||
guild_id = int(parts[2])
|
||||
current_page = int(parts[3])
|
||||
is_all = bool(int(parts[4]))
|
||||
if interaction.author.id != user_id:
|
||||
return
|
||||
taches = await lister_toutes_taches(guild_id) if is_all else await lister_taches_utilisateur(user_id, guild_id)
|
||||
taches_list = list(taches.items()) if taches else []
|
||||
if (current_page + 1) * 4 < len(taches_list):
|
||||
new_page = current_page + 1
|
||||
await interaction.response.edit_message(
|
||||
components=get_taches_pagination_components(user_id, guild_id, taches_list, new_page, is_all))
|
||||
|
||||
elif action == "taches_back_to_menu":
|
||||
if interaction.author.id != int(parts[1]):
|
||||
return
|
||||
guild_id = int(parts[2])
|
||||
await interaction.response.edit_message(
|
||||
components=get_main_menu_components(guild_id))
|
||||
|
||||
# Détails tâche
|
||||
elif action == "tache_detail":
|
||||
tache_id = parts[1]
|
||||
user_id = int(parts[2])
|
||||
guild_id = int(parts[3])
|
||||
page = int(parts[4])
|
||||
is_all = bool(int(parts[5]))
|
||||
if interaction.author.id != user_id:
|
||||
await interaction.response.send_message("❌ Non autorisé.", ephemeral=True)
|
||||
return
|
||||
tache = await get_tache(tache_id)
|
||||
if not tache:
|
||||
await interaction.response.edit_message(
|
||||
components=get_taches_pagination_components(user_id, guild_id, [], page, is_all))
|
||||
return
|
||||
if isinstance(tache, dict) and len(tache) == 1:
|
||||
tache = next(iter(tache.values()))
|
||||
can_edit = tache.get("assigne") == user_id or tache.get("cree_par") == user_id
|
||||
await interaction.response.edit_message(
|
||||
components=get_tache_detail_components(tache, tache_id, user_id, guild_id, can_edit))
|
||||
|
||||
# Actions tâche
|
||||
elif action == "tache_done":
|
||||
tache_id = parts[1]
|
||||
guild_id = int(parts[2])
|
||||
tache = await get_tache(tache_id)
|
||||
if not tache:
|
||||
await interaction.response.send_message("❌ Tâche introuvable.", ephemeral=True)
|
||||
return
|
||||
if isinstance(tache, dict) and len(tache) == 1:
|
||||
tache = next(iter(tache.values()))
|
||||
if interaction.author.id != tache.get("assigne") and interaction.author.id != tache.get("cree_par"):
|
||||
await interaction.response.send_message("❌ Non autorisé.", ephemeral=True)
|
||||
return
|
||||
success = await update_tache_status(tache_id, "Terminée")
|
||||
if success:
|
||||
tache["statut"] = "Terminée"
|
||||
await interaction.response.edit_message(
|
||||
components=get_tache_done_components(tache, interaction.author.id, guild_id))
|
||||
else:
|
||||
await interaction.response.send_message("❌ Erreur.", ephemeral=True)
|
||||
|
||||
elif action == "tache_prio":
|
||||
tache_id = parts[1]
|
||||
guild_id = int(parts[2])
|
||||
tache = await get_tache(tache_id)
|
||||
if not tache:
|
||||
await interaction.response.send_message("❌ Tâche introuvable.", ephemeral=True)
|
||||
return
|
||||
if isinstance(tache, dict) and len(tache) == 1:
|
||||
tache = next(iter(tache.values()))
|
||||
if interaction.author.id != tache.get("assigne") and interaction.author.id != tache.get("cree_par"):
|
||||
await interaction.response.send_message("❌ Non autorisé.", ephemeral=True)
|
||||
return
|
||||
current_prio = tache.get("priorite", "Moyenne")
|
||||
await interaction.response.edit_message(
|
||||
components=get_change_priority_components(tache_id, current_prio, interaction.author.id, guild_id))
|
||||
|
||||
elif action == "tache_delete":
|
||||
tache_id = parts[1]
|
||||
guild_id = int(parts[2])
|
||||
tache = await get_tache(tache_id)
|
||||
if not tache:
|
||||
await interaction.response.send_message("❌ Tâche introuvable.", ephemeral=True)
|
||||
return
|
||||
if isinstance(tache, dict) and len(tache) == 1:
|
||||
tache = next(iter(tache.values()))
|
||||
if interaction.author.id != tache.get("assigne") and interaction.author.id != tache.get("cree_par"):
|
||||
await interaction.response.send_message("❌ Non autorisé.", ephemeral=True)
|
||||
return
|
||||
tache_titre = tache.get("titre", "Tâche")
|
||||
modal = ConfirmDeleteModal(tache_id, tache_titre)
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
# Retour
|
||||
elif action == "tache_back":
|
||||
guild_id = int(parts[1])
|
||||
await interaction.response.edit_message(
|
||||
components=get_main_menu_components(guild_id))
|
||||
|
||||
elif action == "taches_back":
|
||||
guild_id = int(parts[1])
|
||||
await interaction.response.edit_message(
|
||||
components=get_main_menu_components(guild_id))
|
||||
|
||||
# Changement de priorité
|
||||
elif action == "prio_select":
|
||||
tache_id = parts[1]
|
||||
new_prio = parts[2]
|
||||
user_id = int(parts[3])
|
||||
guild_id = int(parts[4])
|
||||
if interaction.author.id != user_id:
|
||||
await interaction.response.send_message("❌ Non autorisé.", ephemeral=True)
|
||||
return
|
||||
success = await update_tache_priorite(tache_id, new_prio)
|
||||
if success:
|
||||
await interaction.response.edit_message(
|
||||
components=get_main_menu_components(guild_id))
|
||||
await interaction.followup.send(f"✅ Priorité mise à jour : **{new_prio}**", ephemeral=True)
|
||||
else:
|
||||
await interaction.response.send_message("❌ Erreur.", ephemeral=True)
|
||||
|
||||
elif action == "prio_cancel":
|
||||
guild_id = int(parts[3])
|
||||
await interaction.response.edit_message(
|
||||
components=get_main_menu_components(guild_id))
|
||||
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Erreur dans on_taches_interaction: {e}", exc_info=True)
|
||||
try:
|
||||
await interaction.response.send_message("❌ Une erreur est survenue.", ephemeral=True)
|
||||
except:
|
||||
pass
|
||||
|
||||
# ==========================================================================
|
||||
# COMMANDE SLASH
|
||||
# ==========================================================================
|
||||
|
||||
@commands.slash_command(name="taches", description="Ouvrir le menu de gestion des tâches")
|
||||
@check_premium_tier()
|
||||
async def taches_command(self, inter: disnake.ApplicationCommandInteraction):
|
||||
"""Commande principale pour accéder au menu des tâches."""
|
||||
settings = await get_taches_settings(inter.guild.id)
|
||||
if not settings.get("taches_enabled", True):
|
||||
return await inter.response.send_message(
|
||||
"❌ Le système de tâches est désactivé sur ce serveur.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
await inter.response.send_message(
|
||||
components=get_main_menu_components(inter.guild.id)
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# BOUCLE DE RAPPELS
|
||||
# ==========================================================================
|
||||
|
||||
@tasks.loop(minutes=15)
|
||||
async def rappels_loop(self):
|
||||
"""Vérifie les tâches proches de leur échéance tous les 15 minutes."""
|
||||
for guild in self.bot.guilds:
|
||||
try:
|
||||
settings = await get_taches_settings(guild.id)
|
||||
if not settings.get("taches_enabled", True):
|
||||
continue
|
||||
|
||||
taches = await taches_proches(guild.id, heures_avant=1)
|
||||
for tid, tache in taches.items():
|
||||
try:
|
||||
user = await self.bot.fetch_user(tache["assigne"])
|
||||
if user:
|
||||
statut_emoji = _get_statut_emoji(tache.get("statut", "À faire"))
|
||||
priorite_emoji = _get_priorite_emoji(tache.get("priorite", "Moyenne"))
|
||||
|
||||
await user.send(
|
||||
content=(
|
||||
f"⏰ **Rappel : Tâche #{tid}**\n\n"
|
||||
f"{statut_emoji} **{tache['titre']}** {priorite_emoji}\n"
|
||||
f"📅 **Échéance dans 1 heure**\n"
|
||||
f"👤 Assigné par : <@{tache['cree_par']}>"
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Erreur rappel tâche {tid}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"Erreur dans rappels_loop pour {guild.id}: {e}")
|
||||
|
||||
@rappels_loop.before_loop
|
||||
async def avant_rappels(self):
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SETUP
|
||||
# ============================================================================
|
||||
|
||||
def setup(bot):
|
||||
"""Setup du cog Tâches."""
|
||||
bot.add_cog(TachesCog(bot))
|
||||
177
commandes/team_json.py
Normal file
177
commandes/team_json.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Team JSON Generator
|
||||
===================
|
||||
Ce module parcourt les membres d'un serveur Discord, filtre ceux qui possèdent
|
||||
un ou plusieurs rôles spécifiés, télécharge leurs avatars et génère un fichier
|
||||
JSON compatible avec le site de production.
|
||||
|
||||
Le JSON attendu ressemble à :
|
||||
[
|
||||
{
|
||||
"id": "123456789012345678",
|
||||
"name": "Nom affiché",
|
||||
"role": "Nom du rôle filtré",
|
||||
"avatar": "123456789012345678.png",
|
||||
"bio": "",
|
||||
"stats": [],
|
||||
"skills": [],
|
||||
"social": {"discord":"","twitter":"",...}
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
Le champ ``avatar`` correspond au nom de fichier stocké dans ``website/avatars/``.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import aiohttp
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
import disnake
|
||||
from disnake.ext import commands, tasks
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Configuration – variables d'environnement (dans le .env du projet)
|
||||
# ----------------------------------------------------------------------
|
||||
TARGET_GUILD_ID = os.getenv("TARGET_GUILD_ID") # ID du serveur contenant l'équipe
|
||||
# Liste d'IDs de rôles séparés par des virgules (ex. "123,456,789")
|
||||
TEAM_ROLE_IDS = os.getenv("TEAM_ROLE_IDS", "").split(",") if os.getenv("TEAM_ROLE_IDS") else []
|
||||
|
||||
# Dossiers où seront stockés les avatars et le JSON final
|
||||
AVATAR_ROOT = Path(__file__).parents[2] / "website" / "avatars"
|
||||
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
JSON_OUTPUT = Path(__file__).parents[2] / "website" / "team.json"
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
class TeamJSON(commands.Cog):
|
||||
"""Cog responsable de la génération du JSON d'équipe et du téléchargement des avatars."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
# Démarre la tâche quotidienne (synchronisation 24h/24)
|
||||
self.sync_daily.start()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@tasks.loop(hours=24)
|
||||
async def sync_daily(self):
|
||||
"""Tâche planifiée exécutée chaque jour (cron)."""
|
||||
await self._generate_team(trigger="cron")
|
||||
|
||||
@sync_daily.before_loop
|
||||
async def before_sync(self):
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
async def _download_avatar(self, session: aiohttp.ClientSession, member: disnake.Member) -> str:
|
||||
"""Télécharge l'avatar du *member* et le sauvegarde dans ``AVATAR_ROOT``.
|
||||
Retourne le nom de fichier (ex. ``"123456789.png"``)."""
|
||||
avatar_url = str(member.display_avatar.url)
|
||||
filename = f"{member.id}.png"
|
||||
dest_path = AVATAR_ROOT / filename
|
||||
try:
|
||||
async with session.get(avatar_url) as resp:
|
||||
if resp.status == 200:
|
||||
dest_path.write_bytes(await resp.read())
|
||||
else:
|
||||
self.bot.logger.warning(
|
||||
f"[TeamJSON] Avatar download failed for {member.id} – HTTP {resp.status}"
|
||||
)
|
||||
except Exception as exc:
|
||||
self.bot.logger.error(f"[TeamJSON] Exception while downloading avatar {member.id}: {exc}")
|
||||
return filename
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
async def _generate_team(self, *, trigger: str = "manual") -> None:
|
||||
"""Construit le JSON de l'équipe et télécharge les avatars.
|
||||
|
||||
Le JSON produit suit exactement le format attendu par le site de production.
|
||||
Les champs ``stats``, ``skills`` et ``social`` sont laissés vides car ils ne
|
||||
sont pas disponibles via l'API bot.
|
||||
"""
|
||||
if not TARGET_GUILD_ID:
|
||||
self.bot.logger.error("[TeamJSON] TARGET_GUILD_ID non défini – arrêt du générateur.")
|
||||
return
|
||||
if not TEAM_ROLE_IDS:
|
||||
self.bot.logger.error("[TeamJSON] TEAM_ROLE_IDS non défini – arrêt du générateur.")
|
||||
return
|
||||
|
||||
# Récupération de la guilde
|
||||
guild = self.bot.get_guild(int(TARGET_GUILD_ID))
|
||||
if not guild:
|
||||
try:
|
||||
guild = await self.bot.fetch_guild(int(TARGET_GUILD_ID))
|
||||
except Exception as exc:
|
||||
self.bot.logger.error(f"[TeamJSON] Impossible de récupérer la guilde {TARGET_GUILD_ID}: {exc}")
|
||||
return
|
||||
|
||||
# Convertir les IDs de rôle en entiers pour la comparaison
|
||||
role_ids = {int(r) for r in TEAM_ROLE_IDS if r.strip()}
|
||||
members_data: List[dict] = []
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async for member in guild.fetch_members(limit=None):
|
||||
if member.bot:
|
||||
continue
|
||||
# Intersection entre les rôles du membre et les rôles ciblés
|
||||
member_role_ids = {r.id for r in member.roles}
|
||||
intersect = member_role_ids.intersection(role_ids)
|
||||
if not intersect:
|
||||
continue # le membre ne possède aucun des rôles recherchés
|
||||
|
||||
# On prend le premier rôle correspondant comme "role" dans le JSON
|
||||
role_name = next((r.name for r in member.roles if r.id in intersect), "")
|
||||
avatar_file = await self._download_avatar(session, member)
|
||||
|
||||
entry = {
|
||||
"id": str(member.id),
|
||||
"name": member.display_name,
|
||||
"role": role_name,
|
||||
"avatar": avatar_file,
|
||||
"bio": "", # non récupérable via l'API bot
|
||||
"stats": [],
|
||||
"skills": [],
|
||||
"social": {
|
||||
"discord": "",
|
||||
"twitter": "",
|
||||
"instagram": "",
|
||||
"youtube": "",
|
||||
"tiktok": "",
|
||||
"github": "",
|
||||
},
|
||||
}
|
||||
members_data.append(entry)
|
||||
|
||||
# Écriture du JSON final
|
||||
try:
|
||||
JSON_OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
JSON_OUTPUT.write_text(
|
||||
json.dumps(members_data, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.bot.logger.info(
|
||||
f"[TeamJSON] JSON généré ({len(members_data)} membres) → {JSON_OUTPUT}"
|
||||
)
|
||||
except Exception as exc:
|
||||
self.bot.logger.error(f"[TeamJSON] Échec de l'écriture du JSON : {exc}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@commands.slash_command(
|
||||
name="generate_team_json",
|
||||
description="Génère le JSON de l'équipe (admin only)"
|
||||
)
|
||||
@check_premium_tier()
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def generate_team_json(self, inter: disnake.ApplicationCommandInteraction):
|
||||
"""Commande manuelle pour (re)générer le JSON et les avatars."""
|
||||
await inter.response.defer(ephemeral=True)
|
||||
await self._generate_team(trigger="manual")
|
||||
await inter.edit_original_response(
|
||||
content="✅ JSON de l'équipe généré et avatars synchronisés."
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
def setup(bot: commands.Bot):
|
||||
bot.add_cog(TeamJSON(bot))
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# Fix Plan: Missing _config_roles_callback method
|
||||
|
||||
## Problem
|
||||
The `TicketSetupView` class has a button that references `_config_roles_callback`, but this method doesn't exist. This causes an `AttributeError` when the `/ticketconfig` command is used.
|
||||
|
||||
## Solution
|
||||
1. Add `_config_roles_callback` method to `TicketSetupView` class
|
||||
2. Create `ConfigRolesSetupView` class to handle config roles selection
|
||||
|
||||
## Files to modify
|
||||
- `commandes/ticket/__init__.py`
|
||||
|
||||
## Changes
|
||||
1. Add `_config_roles_callback` method in `TicketSetupView` (after `_categories_callback`)
|
||||
2. Add `ConfigRolesSetupView` class (after `StaffRolesSetupView`)
|
||||
|
||||
## Status
|
||||
- [x] Add `_config_roles_callback` method
|
||||
- [x] Add `ConfigRolesSetupView` class
|
||||
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
# Ticket System Fixes - Implementation Status
|
||||
|
||||
## ✅ Completed Fixes
|
||||
|
||||
### 1. UI Synchronization Issue
|
||||
- **Problem**: TicketManagementView._claim_callback didn't update Discord UI after removing claim button
|
||||
- **Fix**: Added `await interaction.response.edit_message(view=self)` after view modification
|
||||
- **Status**: ✅ FIXED
|
||||
|
||||
### 2. Resource Leak and API Overload
|
||||
- **Problem**: @tasks.loop(seconds=30) on _register_views caused rate limits and memory consumption
|
||||
- **Fix**: Moved view registration to cog_load() method, removed looped task
|
||||
- **Status**: ✅ FIXED
|
||||
|
||||
### 3. Permission Logic in _reopen_callback
|
||||
- **Problem**: Loop overwrote permissions for all members instead of targeting ticket.user_id
|
||||
- **Fix**: Modified logic to specifically restore send_messages permission for ticket.user_id only
|
||||
- **Status**: ✅ FIXED
|
||||
|
||||
### 4. Missing Error Handling in Modals
|
||||
- **Problem**: TicketPriorityModal and CloseTicketModal lacked on_error methods
|
||||
- **Fix**: Added try/except blocks in on_submit and implemented on_error methods
|
||||
- **Status**: ✅ FIXED
|
||||
|
||||
### 5. Input Validation in StaffRolesModal
|
||||
- **Problem**: Regex (\d{17,20}) was too permissive, didn't validate role existence
|
||||
- **Fix**: Added validation to check if extracted role IDs correspond to existing guild roles
|
||||
- **Status**: ✅ FIXED
|
||||
|
||||
### 6. Circular Import Risk
|
||||
- **Problem**: Local imports suggested potential circular dependencies
|
||||
- **Fix**: Verified models.py doesn't import from __init__.py - no circular import exists
|
||||
- **Status**: ✅ VERIFIED (No fix needed)
|
||||
|
||||
## 🧪 Testing Recommendations
|
||||
|
||||
1. **UI Updates**: Test ticket claiming - UI should update immediately after claiming
|
||||
2. **Performance**: Monitor API usage - should be significantly reduced
|
||||
3. **Permissions**: Test ticket reopening - only ticket creator should regain send_messages
|
||||
4. **Error Handling**: Trigger errors in modals to verify graceful failure
|
||||
5. **Role Validation**: Try invalid role IDs in staff configuration
|
||||
6. **Import Safety**: Verify no import errors on bot startup
|
||||
|
||||
## 📋 Files Modified
|
||||
- `commandes/ticket/__init__.py`: All fixes applied
|
||||
- `commandes/ticket/data/models.py`: Verified for circular imports (none found)
|
||||
|
||||
## 🎯 Impact
|
||||
- Improved user experience with immediate UI updates
|
||||
- Reduced API load and memory usage
|
||||
- Enhanced security with proper permission handling
|
||||
- Better error resilience
|
||||
- More robust input validation
|
||||
- Maintained clean architecture
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
# Ticket Close Confirmation and Delay - Implementation Plan
|
||||
|
||||
## Objective
|
||||
Add confirmation dialog and waiting delay before ticket closure.
|
||||
|
||||
## Changes to make
|
||||
|
||||
### 1. Create `CloseConfirmationView` class
|
||||
- Yes/No confirmation buttons
|
||||
- Cancel option
|
||||
- Shows before closing
|
||||
|
||||
### 2. Create `CloseDelayView` class
|
||||
- Countdown display (5 seconds)
|
||||
- Progress steps:
|
||||
- 📄 Génération du transcript...
|
||||
- 📝 Sauvegarde des données...
|
||||
- 🗑️ Suppression du canal...
|
||||
- Cancel option during delay
|
||||
|
||||
### 3. Modify `TicketManagementView._close_callback`
|
||||
- Show confirmation view first
|
||||
- After Yes, show delay view with countdown
|
||||
- After delay completes, proceed with closing
|
||||
|
||||
## Files to modify
|
||||
- `commandes/ticket/__init__.py`
|
||||
|
||||
## Status
|
||||
- [x] Add CloseConfirmationView class ✅
|
||||
- [x] Add CloseDelayView class ✅
|
||||
- [x] Modify TicketManagementView._close_callback ✅
|
||||
- [x] Add cancel button to CloseDelayView ✅
|
||||
- [x] Test the flow ✅
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### CloseConfirmationView
|
||||
```python
|
||||
class CloseConfirmationView(discord.ui.View):
|
||||
"""Confirmation dialog before closing ticket"""
|
||||
|
||||
def __init__(self, bot, storage, config, ticket):
|
||||
super().__init__(timeout=60)
|
||||
# Yes button (green)
|
||||
# No button (red) - cancels and removes message
|
||||
```
|
||||
|
||||
### CloseDelayView
|
||||
```python
|
||||
class CloseDelayView(discord.ui.View):
|
||||
"""Countdown and progress before ticket deletion"""
|
||||
|
||||
def __init__(self, bot, storage, config, ticket, close_reason):
|
||||
super().__init__(timeout=None)
|
||||
# Disable all buttons during countdown
|
||||
# Update embed with countdown
|
||||
# After delay, call actual close function
|
||||
```
|
||||
|
||||
## Flow
|
||||
1. User clicks "Fermer"
|
||||
2. Show confirmation embed with Yes/No buttons
|
||||
3. If Yes → Show delay view with countdown
|
||||
4. After delay → Close ticket and delete channel
|
||||
5. If No or timeout → Remove confirmation message
|
||||
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# TODO - Correction du système de tickets pour les rôles staff/config
|
||||
|
||||
## Problème
|
||||
Les utilisateurs avec les rôles staff et config ne peuvent pas interagir avec le système de tickets.
|
||||
|
||||
## Cause
|
||||
La méthode `_is_whitelisted` retourne `False` quand le cog WhitelistMonitor n'est pas disponible, les utilisateurs bloquant même staff/config.
|
||||
|
||||
## Plan de correction
|
||||
|
||||
### Étape 1: Corriger TicketPanelView._is_whitelisted()
|
||||
- [ ] Modifier pour que les rôles staff/config soient toujours autorisés
|
||||
|
||||
### Étape 2: Corriger CategorySelectionView._is_whitelisted()
|
||||
- [ ] Modifier pour que les rôles staff/config soient toujours autorisés
|
||||
|
||||
### Étape 3: Vérifier les autres vues
|
||||
- [ ] Vérifier TicketManagementView._is_staff()
|
||||
|
||||
## État d'avancement
|
||||
- [x] TicketPanelView._is_whitelisted() corrigé
|
||||
- [x] CategorySelectionView._is_whitelisted() corrigé
|
||||
- [x] Tests de vérification
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -16,9 +16,8 @@ from PIL import Image, ImageDraw, ImageFont
|
|||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
import disnake
|
||||
from disnake.ext import commands
|
||||
|
||||
from .data.storage import get_storage
|
||||
from .data.models import TicketData, TicketStatus, GuildTicketConfig
|
||||
|
|
@ -31,7 +30,7 @@ class TicketAnalytics(commands.Cog):
|
|||
self.bot = bot
|
||||
self.storage = get_storage()
|
||||
|
||||
def _is_staff(self, interaction: discord.Interaction) -> bool:
|
||||
def _is_staff(self, interaction: disnake.Interaction) -> bool:
|
||||
"""Check if user is staff"""
|
||||
guild = interaction.guild
|
||||
user = interaction.user
|
||||
|
|
@ -363,9 +362,8 @@ class TicketAnalytics(commands.Cog):
|
|||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
@app_commands.command(name="ticket_analytics", description="Affiche les statistiques détaillées des tickets (Staff uniquement)")
|
||||
@app_commands.describe(period="Période d'analyse (7d, 30d, 90d, all)")
|
||||
async def ticket_analytics(self, interaction: discord.Interaction, period: str = "30d"):
|
||||
@commands.slash_command(name="ticket_analytics", description="Affiche les statistiques détaillées des tickets (Staff uniquement)")
|
||||
async def ticket_analytics(self, interaction: disnake.Interaction, period: str = commands.Param(default="30d", choices=["7d", "30d", "90d", "all"], description="Période d'analyse")):
|
||||
"""Display comprehensive ticket analytics for staff"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
|
|
@ -405,10 +403,10 @@ class TicketAnalytics(commands.Cog):
|
|||
t.created_at >= cutoff_date for t in filtered_tickets if hasattr(t, 'response_time') and t.response_time == rt)]
|
||||
|
||||
# Create main embed
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title="📊 Analytics des Tickets",
|
||||
description=f"Statistiques pour la période: **{period}**",
|
||||
color=discord.Color.blue(),
|
||||
color=disnake.Color.blue(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
|
|
@ -444,7 +442,7 @@ class TicketAnalytics(commands.Cog):
|
|||
rating_chart = self._create_rating_chart(stats['ratings'])
|
||||
await interaction.followup.send(
|
||||
"📊 **Distribution des Notes Clients**",
|
||||
file=discord.File(rating_chart, 'rating_distribution.png'),
|
||||
file=disnake.File(rating_chart, 'rating_distribution.png'),
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
|
@ -453,7 +451,7 @@ class TicketAnalytics(commands.Cog):
|
|||
response_chart = self._create_response_time_chart(stats['response_times'])
|
||||
await interaction.followup.send(
|
||||
"⏱️ **Temps de Réponse**",
|
||||
file=discord.File(response_chart, 'response_times.png'),
|
||||
file=disnake.File(response_chart, 'response_times.png'),
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
|
@ -462,7 +460,7 @@ class TicketAnalytics(commands.Cog):
|
|||
trend_chart = self._create_trend_chart(stats['tickets_by_day'])
|
||||
await interaction.followup.send(
|
||||
"📈 **Évolution des Tickets**",
|
||||
file=discord.File(trend_chart, 'ticket_trends.png'),
|
||||
file=disnake.File(trend_chart, 'ticket_trends.png'),
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
|
@ -471,10 +469,10 @@ class TicketAnalytics(commands.Cog):
|
|||
|
||||
# Send feedback summary if available
|
||||
if stats['feedbacks']:
|
||||
feedback_embed = discord.Embed(
|
||||
feedback_embed = disnake.Embed(
|
||||
title="💬 Commentaires Clients",
|
||||
description=f"Derniers commentaires ({min(5, len(stats['feedbacks']))} affichés)",
|
||||
color=discord.Color.green()
|
||||
color=disnake.Color.green()
|
||||
)
|
||||
|
||||
# Sort by rating (lowest first to show areas for improvement)
|
||||
|
|
@ -492,6 +490,6 @@ class TicketAnalytics(commands.Cog):
|
|||
await interaction.followup.send(embed=feedback_embed, ephemeral=True)
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
def setup(bot):
|
||||
"""Setup function for the analytics cog"""
|
||||
await bot.add_cog(TicketAnalytics(bot))
|
||||
bot.add_cog(TicketAnalytics(bot))
|
||||
|
|
|
|||
|
|
@ -59,14 +59,18 @@ class TicketCategory:
|
|||
auto_close_days: int = 7,
|
||||
priority_enabled: bool = True,
|
||||
allow_claims: bool = True,
|
||||
survey_enabled: bool = True
|
||||
survey_enabled: bool = True,
|
||||
discord_category_id: Optional[int] = None,
|
||||
is_recruitment: bool = False,
|
||||
questions: List[str] = None,
|
||||
responses_channel_id: Optional[int] = None
|
||||
):
|
||||
self.id = id
|
||||
self.name = name
|
||||
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
|
||||
|
|
@ -74,7 +78,11 @@ class TicketCategory:
|
|||
self.priority_enabled = priority_enabled
|
||||
self.allow_claims = allow_claims
|
||||
self.survey_enabled = survey_enabled
|
||||
|
||||
self.discord_category_id = discord_category_id
|
||||
self.is_recruitment = is_recruitment
|
||||
self.questions = questions or []
|
||||
self.responses_channel_id = responses_channel_id
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert category to dictionary for storage"""
|
||||
return {
|
||||
|
|
@ -90,7 +98,11 @@ class TicketCategory:
|
|||
"auto_close_days": self.auto_close_days,
|
||||
"priority_enabled": self.priority_enabled,
|
||||
"allow_claims": self.allow_claims,
|
||||
"survey_enabled": self.survey_enabled
|
||||
"survey_enabled": self.survey_enabled,
|
||||
"discord_category_id": self.discord_category_id,
|
||||
"is_recruitment": self.is_recruitment,
|
||||
"questions": self.questions,
|
||||
"responses_channel_id": self.responses_channel_id
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
|
@ -109,7 +121,11 @@ class TicketCategory:
|
|||
auto_close_days=data.get("auto_close_days", 7),
|
||||
priority_enabled=data.get("priority_enabled", True),
|
||||
allow_claims=data.get("allow_claims", True),
|
||||
survey_enabled=data.get("survey_enabled", True)
|
||||
survey_enabled=data.get("survey_enabled", True),
|
||||
discord_category_id=data.get("discord_category_id"),
|
||||
is_recruitment=data.get("is_recruitment", False),
|
||||
questions=data.get("questions", []),
|
||||
responses_channel_id=data.get("responses_channel_id")
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -184,7 +200,8 @@ class TicketData:
|
|||
transcript_path: Optional[str] = None
|
||||
rating: Optional[int] = None
|
||||
feedback: Optional[str] = None
|
||||
|
||||
recruitment_responses: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self.status in [TicketStatus.OPEN, TicketStatus.CLAIMED]
|
||||
|
|
@ -210,9 +227,10 @@ class TicketData:
|
|||
"reason": self.reason,
|
||||
"transcript_path": self.transcript_path,
|
||||
"rating": self.rating,
|
||||
"feedback": self.feedback
|
||||
"feedback": self.feedback,
|
||||
"recruitment_responses": self.recruitment_responses
|
||||
}
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'TicketData':
|
||||
messages = [TicketMessage.from_dict(m) for m in data.get("messages", [])]
|
||||
|
|
@ -230,7 +248,8 @@ class TicketData:
|
|||
reason=data.get("reason", ""),
|
||||
transcript_path=data.get("transcript_path"),
|
||||
rating=data.get("rating"),
|
||||
feedback=data.get("feedback")
|
||||
feedback=data.get("feedback"),
|
||||
recruitment_responses=data.get("recruitment_responses", {})
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -282,6 +301,7 @@ class GuildTicketConfig:
|
|||
claim_enabled: Whether claiming is enabled by default
|
||||
survey_enabled: Whether surveys are enabled by default
|
||||
panel_channel_id: Channel for the ticket panel
|
||||
panel_message_id: Message ID of the ticket panel (for auto-refresh)
|
||||
welcome_enabled: Whether welcome messages are shown
|
||||
priority_enabled: Whether priority selection is enabled
|
||||
"""
|
||||
|
|
@ -303,6 +323,7 @@ class GuildTicketConfig:
|
|||
claim_enabled: bool = True
|
||||
survey_enabled: bool = True
|
||||
panel_channel_id: Optional[int] = None
|
||||
panel_message_id: Optional[int] = None
|
||||
welcome_enabled: bool = True
|
||||
priority_enabled: bool = True
|
||||
|
||||
|
|
@ -327,6 +348,7 @@ class GuildTicketConfig:
|
|||
"claim_enabled": self.claim_enabled,
|
||||
"survey_enabled": self.survey_enabled,
|
||||
"panel_channel_id": self.panel_channel_id,
|
||||
"panel_message_id": self.panel_message_id,
|
||||
"welcome_enabled": self.welcome_enabled,
|
||||
"priority_enabled": self.priority_enabled
|
||||
}
|
||||
|
|
@ -353,6 +375,7 @@ class GuildTicketConfig:
|
|||
claim_enabled=data.get("claim_enabled", True),
|
||||
survey_enabled=data.get("survey_enabled", True),
|
||||
panel_channel_id=data.get("panel_channel_id"),
|
||||
panel_message_id=data.get("panel_message_id"),
|
||||
welcome_enabled=data.get("welcome_enabled", True),
|
||||
priority_enabled=data.get("priority_enabled", True)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,24 +1,25 @@
|
|||
import discord
|
||||
import disnake
|
||||
from .data.models import TicketData
|
||||
|
||||
class FeedbackView(discord.ui.View):
|
||||
def __init__(self, bot, storage, ticket: TicketData):
|
||||
class FeedbackView(disnake.ui.View):
|
||||
def __init__(self, bot, storage, ticket: TicketData, staff_rating_only: bool = False):
|
||||
super().__init__(timeout=86400) # 24h timeout
|
||||
self.bot = bot
|
||||
self.storage = storage
|
||||
self.ticket = ticket
|
||||
self.rating = 0
|
||||
self.comment = ""
|
||||
self.staff_rating_only = staff_rating_only
|
||||
|
||||
# Add Select Menu for Rating
|
||||
self.add_item(RatingSelect())
|
||||
|
||||
@discord.ui.button(label="📝 Laisser un commentaire", style=discord.ButtonStyle.secondary, custom_id="feedback_comment", row=1)
|
||||
async def comment_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
@disnake.ui.button(label="📝 Laisser un commentaire", style=disnake.ButtonStyle.secondary, custom_id="feedback_comment", row=1)
|
||||
async def comment_button(self, button: disnake.ui.Button, interaction: disnake.Interaction):
|
||||
await interaction.response.send_modal(FeedbackModal(self))
|
||||
|
||||
@discord.ui.button(label="✅ Envoyer", style=discord.ButtonStyle.green, custom_id="feedback_submit", row=1)
|
||||
async def submit_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
@disnake.ui.button(label="✅ Envoyer", style=disnake.ButtonStyle.green, custom_id="feedback_submit", row=1)
|
||||
async def submit_button(self, button: disnake.ui.Button, interaction: disnake.Interaction):
|
||||
if self.rating == 0:
|
||||
await interaction.response.send_message("❌ Veuillez sélectionner une note avant d'envoyer.", ephemeral=True)
|
||||
return
|
||||
|
|
@ -32,7 +33,11 @@ class FeedbackView(discord.ui.View):
|
|||
for child in self.children:
|
||||
child.disabled = True
|
||||
|
||||
await interaction.response.edit_message(content="✅ Merci pour votre retour !", view=self, embed=None)
|
||||
# If this is a staff rating only view, we don't send the normal message
|
||||
if not self.staff_rating_only:
|
||||
await interaction.response.edit_message(content="✅ Merci pour votre retour !", view=self, embed=None)
|
||||
else:
|
||||
await interaction.response.edit_message(content="✅ Note envoyée !", view=self, embed=None)
|
||||
|
||||
# Log feedback if configured
|
||||
try:
|
||||
|
|
@ -61,11 +66,11 @@ class FeedbackView(discord.ui.View):
|
|||
|
||||
if staff_user:
|
||||
# Create feedback embed
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title="📝 Nouveau Commentaire Reçu",
|
||||
description=f"Un utilisateur a laissé un avis sur le ticket #{self.ticket.channel_id}",
|
||||
color=discord.Color.gold() if self.rating < 3 else discord.Color.green(),
|
||||
timestamp=discord.utils.utcnow()
|
||||
color=disnake.Color.gold() if self.rating < 3 else disnake.Color.green(),
|
||||
timestamp=disnake.utils.utcnow()
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
|
|
@ -94,7 +99,7 @@ class FeedbackView(discord.ui.View):
|
|||
try:
|
||||
await staff_user.send(embed=embed)
|
||||
print(f"DEBUG: DM sent successfully to {staff_user}", flush=True)
|
||||
except discord.Forbidden:
|
||||
except disnake.Forbidden:
|
||||
print(f"DEBUG: DM failed - Forbidden (DMs closed)", flush=True)
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Error sending feedback DM: {e}", flush=True)
|
||||
|
|
@ -106,34 +111,37 @@ class FeedbackView(discord.ui.View):
|
|||
except Exception as e:
|
||||
print(f"Error in feedback notification: {e}", flush=True)
|
||||
|
||||
class RatingSelect(discord.ui.Select):
|
||||
class RatingSelect(disnake.ui.Select):
|
||||
def __init__(self):
|
||||
options = [
|
||||
discord.SelectOption(label="⭐⭐⭐⭐⭐ (Excellent)", value="5", description="Service parfait !"),
|
||||
discord.SelectOption(label="⭐⭐⭐⭐ (Très bien)", value="4", description="Très bon service"),
|
||||
discord.SelectOption(label="⭐⭐⭐ (Bien)", value="3", description="Correct"),
|
||||
discord.SelectOption(label="⭐⭐ (Moyen)", value="2", description="Peut mieux faire"),
|
||||
discord.SelectOption(label="⭐ (Mauvais)", value="1", description="Insatisfaisant")
|
||||
disnake.SelectOption(label="⭐⭐⭐⭐⭐ (Excellent)", value="5", description="Service parfait !"),
|
||||
disnake.SelectOption(label="⭐⭐⭐⭐ (Très bien)", value="4", description="Très bon service"),
|
||||
disnake.SelectOption(label="⭐⭐⭐ (Bien)", value="3", description="Correct"),
|
||||
disnake.SelectOption(label="⭐⭐ (Moyen)", value="2", description="Peut mieux faire"),
|
||||
disnake.SelectOption(label="⭐ (Mauvais)", value="1", description="Insatisfaisant")
|
||||
]
|
||||
super().__init__(placeholder="Notez le service...", min_values=1, max_values=1, options=options, row=0)
|
||||
|
||||
async def callback(self, interaction: discord.Interaction):
|
||||
async def callback(self, interaction: disnake.Interaction):
|
||||
self.view.rating = int(self.values[0])
|
||||
await interaction.response.defer()
|
||||
|
||||
class FeedbackModal(discord.ui.Modal):
|
||||
def __init__(self, view):
|
||||
super().__init__(title="Votre avis")
|
||||
class FeedbackModal(disnake.ui.Modal):
|
||||
"""Modal that collects a free‑form comment for a ticket feedback."""
|
||||
def __init__(self, view: FeedbackView):
|
||||
self.view = view
|
||||
self.comment = discord.ui.TextInput(
|
||||
self.comment = disnake.ui.TextInput(
|
||||
label="Commentaire",
|
||||
style=discord.TextStyle.paragraph,
|
||||
style=disnake.TextInputStyle.paragraph,
|
||||
placeholder="Dites-nous ce que vous avez pensé du support...",
|
||||
required=True,
|
||||
max_length=1000
|
||||
max_length=1000,
|
||||
)
|
||||
self.add_item(self.comment)
|
||||
super().__init__(title="Votre avis", components=[self.comment])
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
async def callback(self, interaction: disnake.Interaction):
|
||||
self.view.comment = self.comment.value
|
||||
await interaction.response.send_message("Commentaire enregistré ! N'oubliez pas de valider avec 'Envoyer'.", ephemeral=True)
|
||||
await interaction.response.send_message(
|
||||
"Commentaire enregistré ! N'oubliez pas de valider avec « Envoyer ».",
|
||||
ephemeral=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@ from datetime import datetime, timedelta
|
|||
from collections import defaultdict
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
import discord
|
||||
import disnake
|
||||
import matplotlib.pyplot as plt
|
||||
import plotly.graph_objects as go
|
||||
import plotly.express as px
|
||||
from plotly.subplots import make_subplots
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from .data.storage import get_storage
|
||||
|
|
@ -102,213 +107,74 @@ class StaffAnalytics:
|
|||
return result
|
||||
|
||||
def create_staff_rating_chart(self, staff_list: List[Dict[str, Any]]) -> io.BytesIO:
|
||||
"""Create a comprehensive staff rating chart"""
|
||||
"""Create a comprehensive staff rating chart using matplotlib"""
|
||||
# Filter staff with ratings
|
||||
rated_staff = [s for s in staff_list if s['total_ratings'] > 0]
|
||||
|
||||
if not rated_staff:
|
||||
# Return empty chart
|
||||
img = Image.new('RGB', (800, 400), '#2F3136')
|
||||
fig, ax = plt.subplots(figsize=(10, 6), facecolor='#2F3136')
|
||||
ax.set_facecolor('#2F3136')
|
||||
ax.text(0.5, 0.5, 'Aucune évaluation disponible', ha='center', va='center',
|
||||
transform=ax.transAxes, color='white')
|
||||
ax.set_title('Évaluations des Membres du Staff', color='white')
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136')
|
||||
buf.seek(0)
|
||||
plt.close()
|
||||
return buf
|
||||
|
||||
# Image dimensions
|
||||
width, height = 1200, 700
|
||||
img = Image.new('RGB', (width, height), '#36393F') # Discord dark theme
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Try to load fonts with better Unicode support
|
||||
try:
|
||||
# Try different font paths for better Unicode support
|
||||
font_paths = [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||
"/System/Library/Fonts/Arial.ttf",
|
||||
"arial.ttf"
|
||||
]
|
||||
font_title = None
|
||||
font_label = None
|
||||
font_value = None
|
||||
|
||||
for font_path in font_paths:
|
||||
try:
|
||||
font_title = ImageFont.truetype(font_path, 24)
|
||||
font_label = ImageFont.truetype(font_path, 16)
|
||||
font_value = ImageFont.truetype(font_path, 14)
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if not font_title:
|
||||
raise Exception("No suitable font found")
|
||||
|
||||
except:
|
||||
# Fallback to default with better handling
|
||||
font_title = ImageFont.load_default()
|
||||
font_label = ImageFont.load_default()
|
||||
font_value = ImageFont.load_default()
|
||||
|
||||
# Title with better styling
|
||||
title = "Évaluations des Membres du Staff"
|
||||
# Add background for title
|
||||
title_bbox = draw.textbbox((0, 0), title, font=font_title)
|
||||
title_width = title_bbox[2] - title_bbox[0]
|
||||
title_x = (width - title_width) // 2
|
||||
draw.rectangle([title_x - 20, 15, title_x + title_width + 20, 55], fill='#5865F2', outline='#FFFFFF', width=2)
|
||||
draw.text((width//2, 35), title, fill='white', font=font_title, anchor='mm')
|
||||
|
||||
# Chart dimensions with better spacing
|
||||
chart_left = 200
|
||||
chart_right = width - 100
|
||||
chart_top = 100
|
||||
chart_bottom = height - 200
|
||||
chart_width = chart_right - chart_left
|
||||
chart_height = chart_bottom - chart_top
|
||||
|
||||
# Prepare data - limit to top 8 for better readability
|
||||
top_staff = sorted(rated_staff, key=lambda x: x['average_rating'], reverse=True)[:8]
|
||||
names = []
|
||||
for s in top_staff:
|
||||
name = s['name']
|
||||
# Handle Unicode properly
|
||||
try:
|
||||
# Ensure proper encoding
|
||||
name = name.encode('utf-8').decode('utf-8')
|
||||
except:
|
||||
name = str(name)
|
||||
# Truncate long names
|
||||
if len(name) > 12:
|
||||
name = name[:12] + '...'
|
||||
names.append(name)
|
||||
|
||||
# Sort by average rating
|
||||
rated_staff.sort(key=lambda x: x['average_rating'], reverse=True)
|
||||
|
||||
# Prepare data - limit to top 10 for better readability
|
||||
top_staff = rated_staff[:10]
|
||||
names = [s['name'][:15] + '...' if len(s['name']) > 15 else s['name'] for s in top_staff]
|
||||
ratings = [s['average_rating'] for s in top_staff]
|
||||
counts = [s['total_ratings'] for s in top_staff]
|
||||
|
||||
if not ratings:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
max_rating = 5
|
||||
bar_width = min(80, chart_width // len(ratings)) if ratings else 1
|
||||
spacing = 20
|
||||
|
||||
# Draw background grid
|
||||
for i in range(6):
|
||||
y = chart_bottom - (i * chart_height // 5)
|
||||
draw.line([chart_left, y, chart_right, y], fill='#72767D', width=1)
|
||||
# Rating labels
|
||||
draw.text((chart_left - 40, y), f"{i}", fill='#FFFFFF', font=font_value, anchor='mm')
|
||||
|
||||
# Draw bars with gradient effect
|
||||
for i, (name, rating, count) in enumerate(zip(names, ratings, counts)):
|
||||
bar_height = int((rating / max_rating) * chart_height)
|
||||
|
||||
x1 = chart_left + i * (bar_width + spacing) + spacing//2
|
||||
y1 = chart_bottom - bar_height
|
||||
x2 = x1 + bar_width
|
||||
y2 = chart_bottom
|
||||
|
||||
# Bar color based on rating with better colors
|
||||
if rating >= 4.5:
|
||||
color = '#57F287' # Green
|
||||
shadow_color = '#4CAF50'
|
||||
elif rating >= 3.5:
|
||||
color = '#FEE75C' # Yellow
|
||||
shadow_color = '#FFC107'
|
||||
elif rating >= 2.5:
|
||||
color = '#FAA61A' # Orange
|
||||
shadow_color = '#FF9800'
|
||||
else:
|
||||
color = '#ED4245' # Red
|
||||
shadow_color = '#F44336'
|
||||
|
||||
# Draw shadow for 3D effect
|
||||
draw.rectangle([x1+2, y1+2, x2+2, y2], fill=shadow_color, outline=shadow_color)
|
||||
|
||||
# Main bar
|
||||
draw.rectangle([x1, y1, x2, y2], fill=color, outline='#FFFFFF', width=2)
|
||||
|
||||
# Rating value on top with background
|
||||
rating_text = f"{rating:.1f}⭐"
|
||||
text_bbox = draw.textbbox((0, 0), rating_text, font=font_value)
|
||||
text_width = text_bbox[2] - text_bbox[0]
|
||||
text_x = x1 + bar_width//2
|
||||
draw.rectangle([text_x - text_width//2 - 5, y1 - 25, text_x + text_width//2 + 5, y1 - 5], fill='#36393F', outline='#FFFFFF', width=1)
|
||||
draw.text((text_x, y1 - 15), rating_text, fill='white', font=font_value, anchor='mm')
|
||||
|
||||
# Count below with background
|
||||
count_text = f"({count})"
|
||||
count_bbox = draw.textbbox((0, 0), count_text, font=font_value)
|
||||
count_width = count_bbox[2] - count_bbox[0]
|
||||
draw.rectangle([text_x - count_width//2 - 5, y2 + 5, text_x + count_width//2 + 5, y2 + 25], fill='#36393F', outline='#FFFFFF', width=1)
|
||||
draw.text((text_x, y2 + 15), count_text, fill='white', font=font_value, anchor='mm')
|
||||
|
||||
# Draw staff names with better positioning
|
||||
for i, name in enumerate(names):
|
||||
x = chart_left + i * (bar_width + spacing) + bar_width//2 + spacing//2
|
||||
|
||||
# Handle long names by splitting
|
||||
lines = []
|
||||
if len(name) > 8:
|
||||
# Split at spaces if possible
|
||||
words = name.split()
|
||||
current_line = ""
|
||||
for word in words:
|
||||
if len(current_line + word) < 8:
|
||||
current_line += word + " "
|
||||
else:
|
||||
lines.append(current_line.strip())
|
||||
current_line = word + " "
|
||||
if current_line:
|
||||
lines.append(current_line.strip())
|
||||
if not lines:
|
||||
lines = [name[:8] + '...']
|
||||
else:
|
||||
lines = [name]
|
||||
|
||||
y_offset = chart_bottom + 40
|
||||
for line in lines:
|
||||
# Background for name
|
||||
name_bbox = draw.textbbox((0, 0), line, font=font_label)
|
||||
name_width = name_bbox[2] - name_bbox[0]
|
||||
draw.rectangle([x - name_width//2 - 5, y_offset - 5, x + name_width//2 + 5, y_offset + 15], fill='#36393F', outline='#FFFFFF', width=1)
|
||||
draw.text((x, y_offset + 5), line, fill='white', font=font_label, anchor='mm')
|
||||
y_offset += 20
|
||||
|
||||
# Y-axis label
|
||||
draw.text((chart_left - 120, height//2), 'Note Moyenne', fill='white', font=font_label, anchor='mm')
|
||||
|
||||
# Enhanced legend with better positioning
|
||||
legend_x = chart_right - 250
|
||||
legend_y = chart_top + 50
|
||||
|
||||
# Legend background
|
||||
draw.rectangle([legend_x - 20, legend_y - 10, legend_x + 230, legend_y + 120], fill='#36393F', outline='#FFFFFF', width=2)
|
||||
|
||||
draw.text((legend_x, legend_y), "Légende des Notes:", fill='white', font=font_label)
|
||||
|
||||
legend_items = [
|
||||
("🟢 Excellent", "4.5+", '#57F287'),
|
||||
("🟡 Bon", "3.5-4.4", '#FEE75C'),
|
||||
("🟠 Moyen", "2.5-3.4", '#FAA61A'),
|
||||
("🔴 À améliorer", "<2.5", '#ED4245')
|
||||
# Create figure
|
||||
fig, ax = plt.subplots(figsize=(12, 8), facecolor='#2F3136')
|
||||
fig.patch.set_facecolor('#2F3136')
|
||||
ax.set_facecolor('#2F3136')
|
||||
|
||||
# Create bars with color coding
|
||||
colors = ['#57F287' if r >= 4.5 else '#FEE75C' if r >= 3.5 else '#FAA61A' if r >= 2.5 else '#ED4245' for r in ratings]
|
||||
bars = ax.bar(range(len(ratings)), ratings, color=colors)
|
||||
|
||||
# Customize
|
||||
ax.set_xticks(range(len(names)))
|
||||
ax.set_xticklabels(names, rotation=45, ha='right', color='white')
|
||||
ax.set_ylabel('Note moyenne', color='white')
|
||||
ax.set_title('Évaluations des Membres du Staff', color='white', fontsize=16)
|
||||
ax.tick_params(colors='white')
|
||||
ax.grid(True, alpha=0.3, color='gray')
|
||||
|
||||
# Add value labels on bars (avoiding Unicode stars to prevent warnings)
|
||||
for i, (bar, rating, count) in enumerate(zip(bars, ratings, counts)):
|
||||
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.05,
|
||||
f'{rating:.1f}\n({count})', ha='center', va='bottom', color='white', fontsize=8)
|
||||
|
||||
# Set y-axis limits
|
||||
ax.set_ylim(0, 5.2)
|
||||
|
||||
# Add legend
|
||||
legend_elements = [
|
||||
plt.Rectangle((0,0),1,1, color='#57F287', label='Excellent (4.5+)'),
|
||||
plt.Rectangle((0,0),1,1, color='#FEE75C', label='Bon (3.5-4.4)'),
|
||||
plt.Rectangle((0,0),1,1, color='#FAA61A', label='Moyen (2.5-3.4)'),
|
||||
plt.Rectangle((0,0),1,1, color='#ED4245', label='À améliorer (<2.5)')
|
||||
]
|
||||
|
||||
for i, (emoji_text, range_text, color) in enumerate(legend_items):
|
||||
y_pos = legend_y + 25 + i * 20
|
||||
draw.text((legend_x, y_pos), f"{emoji_text} {range_text}", fill=color, font=font_value)
|
||||
|
||||
# Add some stats at the bottom
|
||||
stats_text = f"Total évalué: {len(rated_staff)} membres • Moyenne générale: {sum(ratings)/len(ratings):.1f}⭐"
|
||||
draw.text((width//2, height - 30), stats_text, fill='#B9BBBE', font=font_value, anchor='mm')
|
||||
|
||||
ax.legend(handles=legend_elements, loc='upper right', facecolor='#2F3136', edgecolor='white', labelcolor='white')
|
||||
|
||||
# Adjust layout
|
||||
plt.tight_layout()
|
||||
|
||||
# Save to buffer
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
plt.savefig(buf, format='png', bbox_inches='tight', facecolor='#2F3136')
|
||||
buf.seek(0)
|
||||
plt.close()
|
||||
return buf
|
||||
|
||||
def create_individual_staff_chart(self, staff_data: Dict[str, Any]) -> io.BytesIO:
|
||||
|
|
@ -388,7 +254,7 @@ class StaffAnalytics:
|
|||
return buf
|
||||
|
||||
|
||||
class StaffStatsView(discord.ui.View):
|
||||
class StaffStatsView(disnake.ui.View):
|
||||
"""View for selecting individual staff member stats"""
|
||||
|
||||
def __init__(self, bot, staff_list: List[Dict[str, Any]], analytics: StaffAnalytics):
|
||||
|
|
@ -406,7 +272,7 @@ class StaffStatsView(discord.ui.View):
|
|||
description = f"Note: {staff['average_rating']:.1f}⭐" if staff['total_ratings'] > 0 else "Aucune évaluation"
|
||||
|
||||
options.append(
|
||||
discord.SelectOption(
|
||||
disnake.SelectOption(
|
||||
label=label,
|
||||
value=str(staff['user_id']),
|
||||
description=description[:50]
|
||||
|
|
@ -414,14 +280,14 @@ class StaffStatsView(discord.ui.View):
|
|||
)
|
||||
|
||||
if options:
|
||||
select = discord.ui.Select(
|
||||
select = disnake.ui.Select(
|
||||
placeholder="Sélectionnez un membre du staff...",
|
||||
options=options
|
||||
)
|
||||
select.callback = self._staff_selected
|
||||
self.add_item(select)
|
||||
|
||||
async def _staff_selected(self, interaction: discord.Interaction):
|
||||
async def _staff_selected(self, interaction: disnake.Interaction):
|
||||
"""Handle staff selection"""
|
||||
staff_id = int(interaction.data["values"][0])
|
||||
staff_data = next((s for s in self.staff_list if s['user_id'] == staff_id), None)
|
||||
|
|
@ -435,10 +301,10 @@ class StaffStatsView(discord.ui.View):
|
|||
# Create individual chart
|
||||
chart = self.analytics.create_individual_staff_chart(staff_data)
|
||||
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title=f"📊 Statistiques de {staff_data['name']}",
|
||||
description="Performances détaillées du membre du staff",
|
||||
color=discord.Color.blue(),
|
||||
color=disnake.Color.blue(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
|
|
@ -465,4 +331,4 @@ class StaffStatsView(discord.ui.View):
|
|||
inline=True
|
||||
)
|
||||
|
||||
await interaction.followup.send(embed=embed, file=discord.File(chart, 'staff_stats.png'), ephemeral=True)
|
||||
await interaction.followup.send(embed=embed, file=disnake.File(chart, 'staff_stats.png'), ephemeral=True)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Beautiful embed and message formatters for the ticket system.
|
|||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from discord import Embed, Color, User, Member, Role
|
||||
from disnake import Embed, Color, User, Member, Role
|
||||
|
||||
from ..data.models import (
|
||||
TicketData,
|
||||
|
|
|
|||
|
|
@ -3,20 +3,19 @@ Ticket Permissions Utility
|
|||
==========================
|
||||
Centralized permission logic for the ticket system.
|
||||
"""
|
||||
import discord
|
||||
import disnake
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..data.models import GuildTicketConfig
|
||||
from ..data.models import GuildTicketConfig, TicketCategory
|
||||
|
||||
def is_staff(interaction: discord.Interaction, config: 'GuildTicketConfig') -> bool:
|
||||
def is_staff(interaction: disnake.Interaction, config: 'GuildTicketConfig', category: Optional['TicketCategory'] = None) -> bool:
|
||||
"""
|
||||
Check if the user has staff permissions.
|
||||
|
||||
Checks:
|
||||
1. Administrator permission
|
||||
2. Staff roles
|
||||
3. Staff users (specific user IDs)
|
||||
If a category is provided and has specific staff_roles defined,
|
||||
only those roles (and admins) are considered staff for this check.
|
||||
Otherwise, global staff roles from the config are used.
|
||||
"""
|
||||
# Get user and permissions based on object type
|
||||
user = getattr(interaction, "author", getattr(interaction, "user", None))
|
||||
|
|
@ -26,20 +25,33 @@ def is_staff(interaction: discord.Interaction, config: 'GuildTicketConfig') -> b
|
|||
if user.guild_permissions.administrator:
|
||||
return True
|
||||
|
||||
|
||||
# Check specific users
|
||||
# Check specific users (always allowed)
|
||||
if user.id in config.staff_users:
|
||||
return True
|
||||
|
||||
# Check roles
|
||||
# If category has specific roles, only those are allowed
|
||||
if category and category.staff_roles:
|
||||
for role_id in category.staff_roles:
|
||||
try:
|
||||
role = interaction.guild.get_role(int(role_id))
|
||||
if role and role in user.roles:
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return False
|
||||
|
||||
# Check global roles
|
||||
for role_id in config.staff_roles:
|
||||
role = interaction.guild.get_role(int(role_id))
|
||||
if role and role in user.roles:
|
||||
return True
|
||||
try:
|
||||
role = interaction.guild.get_role(int(role_id))
|
||||
if role and role in user.roles:
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
def can_access_config(interaction: discord.Interaction, config: 'GuildTicketConfig') -> bool:
|
||||
def can_access_config(interaction: disnake.Interaction, config: 'GuildTicketConfig') -> bool:
|
||||
"""
|
||||
Check if the user can access the configuration.
|
||||
|
||||
|
|
@ -75,7 +87,7 @@ def can_access_config(interaction: discord.Interaction, config: 'GuildTicketConf
|
|||
|
||||
return False
|
||||
|
||||
def is_whitelisted(interaction: discord.Interaction, config: 'GuildTicketConfig', bot) -> bool:
|
||||
def is_whitelisted(interaction: disnake.Interaction, config: 'GuildTicketConfig', bot) -> bool:
|
||||
"""
|
||||
Check if user is whitelisted or has staff/config permissions.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import json
|
|||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from discord import User, Member
|
||||
from disnake import User, Member
|
||||
|
||||
from ..data.models import TicketData, TicketMessage
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
460
commandes/website_sync.py
Normal file
460
commandes/website_sync.py
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
"""
|
||||
Cog : website_sync
|
||||
Commandes pour gérer la synchronisation JSON du site web.
|
||||
|
||||
Slash commands disponibles :
|
||||
/json-generate [cible] — Génère les JSON manuellement
|
||||
/json-status — État des fichiers + prochaine génération
|
||||
/json-preview [cible] [limite] — Aperçu sans écrire les fichiers
|
||||
/json-history [limite] — Historique des N dernières générations
|
||||
/json-config show — Affiche la config actuelle
|
||||
/json-config set-guild <id> — Définit le serveur cible (members.json)
|
||||
/json-config set-schedule <h> [m] — Heure de génération auto
|
||||
/json-config set-output-dir <chemin> — Changer le dossier de sortie
|
||||
/json-config add-founder-role <nom> — Ajouter un nom de rôle fondateur
|
||||
/json-config remove-founder-role <nom> — Retirer un nom de rôle fondateur
|
||||
/json-config server-field <champ> <actif> — Active/désactive un champ servers.json
|
||||
/json-config member-field <champ> <actif> — Active/désactive un champ members.json
|
||||
/json-config reset — Remet la config par défaut
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import disnake
|
||||
from disnake.ext import commands, tasks
|
||||
|
||||
from src.json_export_service import (
|
||||
load_config, save_config, generate_all,
|
||||
generate_servers_json, generate_members_json,
|
||||
preview_servers, preview_members,
|
||||
get_history, log_history,
|
||||
SERVERS_FILE, MEMBERS_FILE, OUTPUT_DIR,
|
||||
DEFAULT_CONFIG,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("kuby.website_sync")
|
||||
|
||||
# Listes des champs disponibles (pour les choix des commandes)
|
||||
SERVER_FIELDS = [
|
||||
"id", "name", "icon", "banner", "owner",
|
||||
"memberCount", "description", "createdAt",
|
||||
"boostLevel", "boostCount", "verificationLevel",
|
||||
]
|
||||
MEMBER_FIELDS = [
|
||||
"id", "username", "displayName", "globalName",
|
||||
"avatar", "bio", "roles", "joinedAt", "createdAt",
|
||||
"badges", "accentColor", "isBot",
|
||||
]
|
||||
|
||||
|
||||
class WebsiteSync(commands.Cog, name="Website Sync"):
|
||||
"""Synchronisation JSON pour le site web."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self._scheduler_task: Optional[asyncio.Task] = None
|
||||
self.daily_export.start()
|
||||
|
||||
def cog_unload(self):
|
||||
self.daily_export.cancel()
|
||||
|
||||
# ── Tâche de fond : génération (1 heure) ────────────────────────────
|
||||
@tasks.loop(hours=1)
|
||||
async def daily_export(self):
|
||||
"""Génère les JSON toutes les heures."""
|
||||
logger.info("[JSON Export] Génération automatique déclenchée.")
|
||||
await generate_all(self.bot)
|
||||
|
||||
@daily_export.before_loop
|
||||
async def before_daily_export(self):
|
||||
await self.bot.wait_until_ready()
|
||||
config = load_config()
|
||||
logger.info(
|
||||
f"[JSON Export] Mode Test actif → génération automatique toutes les 30 secondes."
|
||||
)
|
||||
|
||||
# ── Helper : embed de réponse ─────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _embed(title: str, color: disnake.Color, description: str = "") -> disnake.Embed:
|
||||
e = disnake.Embed(title=title, color=color, timestamp=datetime.now(timezone.utc))
|
||||
if description:
|
||||
e.description = description
|
||||
return e
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# /json-generate
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
@commands.slash_command(name="json-generate", description="Génère manuellement les fichiers JSON du site web")
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def json_generate(self, interaction: disnake.ApplicationCommandInteraction,
|
||||
cible: str = commands.Param("all", description="Quoi générer ?", choices={
|
||||
"Tout (servers + members)": "all",
|
||||
"Serveurs uniquement": "servers",
|
||||
"Membres uniquement": "members"
|
||||
})):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
start = datetime.now()
|
||||
embed = self._embed("⏳ Génération en cours...", disnake.Color.yellow())
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
try:
|
||||
if cible == "servers":
|
||||
res = await generate_servers_json(self.bot)
|
||||
embed = self._embed("✅ servers.json généré", disnake.Color.green())
|
||||
embed.add_field(name="Serveurs", value=str(res["count"]), inline=True)
|
||||
embed.add_field(name="Durée", value=f"{(datetime.now()-start).total_seconds():.1f}s", inline=True)
|
||||
embed.add_field(name="Fichier", value=f"`{SERVERS_FILE}`", inline=False)
|
||||
|
||||
elif cible == "members":
|
||||
res = await generate_members_json(self.bot)
|
||||
embed = self._embed("✅ members.json généré", disnake.Color.green())
|
||||
embed.add_field(name="Membres", value=str(res["count"]), inline=True)
|
||||
embed.add_field(name="Serveur", value=res["guildName"], inline=True)
|
||||
embed.add_field(name="Durée", value=f"{(datetime.now()-start).total_seconds():.1f}s", inline=True)
|
||||
embed.add_field(name="Fichier", value=f"`{MEMBERS_FILE}`", inline=False)
|
||||
|
||||
else: # all
|
||||
res = await generate_all(self.bot, trigger="manual")
|
||||
has_errors = bool(res["errors"])
|
||||
embed = self._embed(
|
||||
"⚠️ Génération partielle" if has_errors else "✅ Génération complète",
|
||||
disnake.Color.orange() if has_errors else disnake.Color.green(),
|
||||
)
|
||||
if res["servers"]:
|
||||
embed.add_field(name="📋 Serveurs", value=f"{res['servers']['count']} serveurs", inline=True)
|
||||
if res["members"]:
|
||||
embed.add_field(name="👥 Membres", value=f"{res['members']['count']} membres", inline=True)
|
||||
embed.add_field(name="Durée totale", value=f"{(datetime.now()-start).total_seconds():.1f}s", inline=True)
|
||||
if has_errors:
|
||||
embed.add_field(name="❌ Erreurs", value="\n".join(res["errors"]), inline=False)
|
||||
embed.add_field(name="📁 Dossier", value=f"`{OUTPUT_DIR}`", inline=False)
|
||||
|
||||
except Exception as e:
|
||||
embed = self._embed("❌ Erreur", disnake.Color.red(), f"```{e}```")
|
||||
|
||||
await interaction.edit_original_response(embed=embed)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# /json-status
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
@commands.slash_command(name="json-status", description="État des fichiers JSON et prochaine génération auto")
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def json_status(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
config = load_config()
|
||||
embed = self._embed("📊 Statut JSON Export", disnake.Color.blurple())
|
||||
|
||||
# servers.json
|
||||
if SERVERS_FILE.exists():
|
||||
try:
|
||||
data = json.loads(SERVERS_FILE.read_text())
|
||||
size = SERVERS_FILE.stat().st_size / 1024
|
||||
gen_at = datetime.fromisoformat(data["generatedAt"]).strftime("%d/%m/%Y %H:%M")
|
||||
embed.add_field(
|
||||
name="🌐 servers.json",
|
||||
value=f"✅ {data['count']} serveurs — {size:.1f} KB\n📅 {gen_at}",
|
||||
inline=False,
|
||||
)
|
||||
except Exception:
|
||||
embed.add_field(name="🌐 servers.json", value="⚠️ Fichier corrompu", inline=False)
|
||||
else:
|
||||
embed.add_field(name="🌐 servers.json", value="❌ Non généré", inline=False)
|
||||
|
||||
# members.json
|
||||
if MEMBERS_FILE.exists():
|
||||
try:
|
||||
data = json.loads(MEMBERS_FILE.read_text())
|
||||
size = MEMBERS_FILE.stat().st_size / 1024
|
||||
gen_at = datetime.fromisoformat(data["generatedAt"]).strftime("%d/%m/%Y %H:%M")
|
||||
embed.add_field(
|
||||
name="👥 members.json",
|
||||
value=f"✅ {data['count']} membres ({data['guildName']}) — {size:.1f} KB\n📅 {gen_at}",
|
||||
inline=False,
|
||||
)
|
||||
except Exception:
|
||||
embed.add_field(name="👥 members.json", value="⚠️ Fichier corrompu", inline=False)
|
||||
else:
|
||||
embed.add_field(name="👥 members.json", value="❌ Non généré", inline=False)
|
||||
|
||||
# Prochain run
|
||||
now = datetime.now()
|
||||
next_run = now.replace(hour=config["autoHour"], minute=config["autoMinute"], second=0, microsecond=0)
|
||||
if next_run <= now:
|
||||
next_run += timedelta(days=1)
|
||||
diff = next_run - now
|
||||
h, m = divmod(int(diff.total_seconds() // 60), 60)
|
||||
in_text = f"dans {h}h {m}min" if h else f"dans {m}min"
|
||||
embed.add_field(
|
||||
name="⏰ Prochaine génération auto",
|
||||
value=f"**{config['autoHour']:02d}:{config['autoMinute']:02d}** — {in_text}\n({next_run.strftime('%d/%m/%Y %H:%M')})",
|
||||
inline=False,
|
||||
)
|
||||
embed.add_field(name="📁 Dossier", value=f"`{OUTPUT_DIR}`", inline=False)
|
||||
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# /json-config (groupe)
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
@commands.slash_command(name="json-config", description="Configure la génération automatique des JSON")
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def json_config_group(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
pass
|
||||
|
||||
# ── show ──────────────────────────────────────────────────────────────────
|
||||
@json_config_group.sub_command(name="show", description="Affiche la configuration actuelle")
|
||||
async def config_show(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
config = load_config()
|
||||
|
||||
srv_on = ", ".join(k for k, v in config["serverFields"].items() if v) or "_Aucun_"
|
||||
srv_off = ", ".join(k for k, v in config["serverFields"].items() if not v) or "_Aucun_"
|
||||
mbr_on = ", ".join(k for k, v in config["memberFields"].items() if v) or "_Aucun_"
|
||||
mbr_off = ", ".join(k for k, v in config["memberFields"].items() if not v) or "_Aucun_"
|
||||
|
||||
embed = self._embed("⚙️ Configuration JSON Export", disnake.Color.blurple())
|
||||
embed.add_field(name="🕐 Génération auto", value=f"{config['autoHour']:02d}:{config['autoMinute']:02d} (heure serveur)", inline=True)
|
||||
embed.add_field(name="🎯 Serveur cible (members)", value=config["targetGuildId"] or "_Non défini_", inline=True)
|
||||
embed.add_field(name="\u200b", value="\u200b", inline=False)
|
||||
embed.add_field(name="✅ servers.json actifs", value=srv_on, inline=False)
|
||||
embed.add_field(name="❌ servers.json inactifs", value=srv_off, inline=False)
|
||||
embed.add_field(name="✅ members.json actifs", value=mbr_on, inline=False)
|
||||
embed.add_field(name="❌ members.json inactifs", value=mbr_off, inline=False)
|
||||
embed.add_field(name="📁 Dossier de sortie", value=f"`{OUTPUT_DIR}`", inline=False)
|
||||
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
# ── set-guild ────────────────────────────────────────────────────────────
|
||||
@json_config_group.sub_command(name="set-guild", description="Définit l'ID du serveur cible pour members.json")
|
||||
async def config_set_guild(self, interaction: disnake.ApplicationCommandInteraction, guild_id: str = commands.Param(description="ID du serveur Discord")):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
try:
|
||||
guild = self.bot.get_guild(int(guild_id)) or await self.bot.fetch_guild(int(guild_id))
|
||||
config = load_config()
|
||||
config["targetGuildId"] = guild_id
|
||||
save_config(config)
|
||||
embed = self._embed(
|
||||
"✅ Serveur cible défini", disnake.Color.green(),
|
||||
f"**{guild.name}** (`{guild_id}`) sera utilisé pour members.json.",
|
||||
)
|
||||
except Exception:
|
||||
embed = self._embed(
|
||||
"❌ Serveur introuvable", disnake.Color.red(),
|
||||
f"Le bot n'est pas dans le serveur `{guild_id}` ou l'ID est invalide.",
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
# ── set-schedule ─────────────────────────────────────────────────────────
|
||||
@json_config_group.sub_command(name="set-schedule", description="Définit l'heure de génération automatique quotidienne")
|
||||
async def config_set_schedule(self, interaction: disnake.ApplicationCommandInteraction,
|
||||
heure: int = commands.Param(description="Heure (0-23)"),
|
||||
minute: int = commands.Param(0, description="Minute (0-59, défaut: 0)")):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
if not (0 <= heure <= 23) or not (0 <= minute <= 59):
|
||||
await interaction.followup.send("❌ Heure invalide.", ephemeral=True)
|
||||
return
|
||||
config = load_config()
|
||||
config["autoHour"] = heure
|
||||
config["autoMinute"] = minute
|
||||
save_config(config)
|
||||
embed = self._embed(
|
||||
"✅ Horaire mis à jour", disnake.Color.green(),
|
||||
f"Prochaine génération auto : **{heure:02d}:{minute:02d}** chaque jour.",
|
||||
)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
# ── server-field ─────────────────────────────────────────────────────────
|
||||
@json_config_group.sub_command(name="server-field", description="Active/désactive un champ dans servers.json")
|
||||
async def config_server_field(self, interaction: disnake.ApplicationCommandInteraction,
|
||||
champ: str = commands.Param(description="Champ à modifier", choices=SERVER_FIELDS),
|
||||
actif: bool = commands.Param(description="true = inclus, false = exclu")):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
config = load_config()
|
||||
config["serverFields"][champ] = actif
|
||||
save_config(config)
|
||||
state = "inclus ✅" if actif else "exclu ❌"
|
||||
embed = self._embed("✅ Champ servers.json mis à jour", disnake.Color.green(),
|
||||
f"`{champ}` est maintenant **{state}** de servers.json.")
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
# ── member-field ─────────────────────────────────────────────────────────
|
||||
@json_config_group.sub_command(name="member-field", description="Active/désactive un champ dans members.json")
|
||||
async def config_member_field(self, interaction: disnake.ApplicationCommandInteraction,
|
||||
champ: str = commands.Param(description="Champ à modifier", choices=MEMBER_FIELDS),
|
||||
actif: bool = commands.Param(description="true = inclus, false = exclu")):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
config = load_config()
|
||||
config["memberFields"][champ] = actif
|
||||
save_config(config)
|
||||
state = "inclus ✅" if actif else "exclu ❌"
|
||||
embed = self._embed("✅ Champ members.json mis à jour", disnake.Color.green(),
|
||||
f"`{champ}` est maintenant **{state}** de members.json.")
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
# ── reset ─────────────────────────────────────────────────────────────────
|
||||
@json_config_group.sub_command(name="reset", description="Remet la configuration par défaut")
|
||||
async def config_reset(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
from src.json_export_service import CONFIG_FILE
|
||||
try:
|
||||
CONFIG_FILE.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
load_config() # recrée depuis DEFAULT_CONFIG
|
||||
embed = self._embed("✅ Configuration réinitialisée", disnake.Color.green(),
|
||||
"Tous les paramètres ont été remis à leurs valeurs par défaut.")
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
# ── set-output-dir ────────────────────────────────────────────────────────
|
||||
@json_config_group.sub_command(name="set-output-dir", description="Change le dossier de sortie des fichiers JSON")
|
||||
async def config_set_output_dir(self, interaction: disnake.ApplicationCommandInteraction,
|
||||
chemin: str = commands.Param(description="Chemin absolu ou relatif vers le dossier (ex: /var/www/site/data)")):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
from pathlib import Path as _Path
|
||||
p = _Path(chemin)
|
||||
try:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
config = load_config()
|
||||
config["outputDir"] = str(p)
|
||||
save_config(config)
|
||||
embed = self._embed(
|
||||
"✅ Dossier de sortie mis à jour", disnake.Color.green(),
|
||||
f"Les JSON seront désormais écrits dans :\n`{p.resolve()}`",
|
||||
)
|
||||
except Exception as e:
|
||||
embed = self._embed("❌ Erreur", disnake.Color.red(), f"Impossible d'utiliser ce chemin :\n```{e}```")
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
# ── add-founder-role ──────────────────────────────────────────────────────
|
||||
@json_config_group.sub_command(name="add-founder-role", description="Ajoute un nom de rôle reconnu comme 'fondateur'")
|
||||
async def config_add_founder_role(self, interaction: disnake.ApplicationCommandInteraction,
|
||||
nom: str = commands.Param(description="Nom du rôle (insensible à la casse, correspondance partielle)")):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
config = load_config()
|
||||
roles = config.get("founderRoles", [])
|
||||
nom_l = nom.lower().strip()
|
||||
if nom_l in [r.lower() for r in roles]:
|
||||
embed = self._embed("ℹ️ Déjà présent", disnake.Color.yellow(),
|
||||
f"`{nom}` est déjà dans la liste des rôles fondateurs.")
|
||||
else:
|
||||
roles.append(nom_l)
|
||||
config["founderRoles"] = roles
|
||||
save_config(config)
|
||||
embed = self._embed(
|
||||
"✅ Rôle fondateur ajouté", disnake.Color.green(),
|
||||
f"`{nom_l}` ajouté. Liste actuelle : {', '.join(f'`{r}`' for r in roles)}",
|
||||
)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
# ── remove-founder-role ───────────────────────────────────────────────────
|
||||
@json_config_group.sub_command(name="remove-founder-role", description="Retire un nom de rôle fondateur")
|
||||
async def config_remove_founder_role(self, interaction: disnake.ApplicationCommandInteraction,
|
||||
nom: str = commands.Param(description="Nom du rôle à retirer")):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
config = load_config()
|
||||
roles = config.get("founderRoles", [])
|
||||
nom_l = nom.lower().strip()
|
||||
updated = [r for r in roles if r.lower() != nom_l]
|
||||
if len(updated) == len(roles):
|
||||
embed = self._embed("ℹ️ Introuvable", disnake.Color.yellow(),
|
||||
f"`{nom}` n'est pas dans la liste des rôles fondateurs.")
|
||||
else:
|
||||
config["founderRoles"] = updated
|
||||
save_config(config)
|
||||
reste = ', '.join(f'`{r}`' for r in updated) or "_Aucun_"
|
||||
embed = self._embed(
|
||||
"✅ Rôle fondateur retiré", disnake.Color.green(),
|
||||
f"`{nom_l}` retiré. Liste actuelle : {reste}",
|
||||
)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# /json-preview
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
@commands.slash_command(name="json-preview", description="Aperçu des données sans écrire les fichiers")
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def json_preview(self, interaction: disnake.ApplicationCommandInteraction,
|
||||
cible: str = commands.Param("servers", description="Quoi prévisualiser ?", choices={"Serveurs": "servers", "Membres": "members"}),
|
||||
limite: int = commands.Param(3, description="Nombre d'éléments à afficher (1-10, défaut: 3)")):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
limite = max(1, min(limite, 10)) # entre 1 et 10
|
||||
|
||||
try:
|
||||
embed = self._embed(f"🔍 Aperçu — {cible}", disnake.Color.blurple())
|
||||
embed.set_footer(text="Aperçu uniquement — aucun fichier écrit")
|
||||
|
||||
if cible == "servers":
|
||||
data = await preview_servers(self.bot, limit=limite)
|
||||
for s in data:
|
||||
val = f"ID: `{s['id']}`"
|
||||
if "memberCount" in s:
|
||||
val += f"\nMembres: {s['memberCount']}"
|
||||
if s.get("icon"):
|
||||
val += f"\nIcon: {s['icon']}"
|
||||
embed.add_field(name=f"🌐 {s['name']}", value=val, inline=False)
|
||||
|
||||
else: # members
|
||||
data = await preview_members(self.bot, limit=limite)
|
||||
embed.description = f"Serveur : **{data['guildName']}**"
|
||||
for m in data["members"]:
|
||||
roles_str = ", ".join(m["roles"][:5]) or "_Aucun_"
|
||||
if len(m["roles"]) > 5:
|
||||
roles_str += f" +{len(m['roles'])-5}"
|
||||
embed.add_field(
|
||||
name=f"👤 {m['displayName']} ({m['username']})",
|
||||
value=f"ID: `{m['id']}`\nRôles: {roles_str}",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
embed = self._embed("❌ Erreur", disnake.Color.red(), f"```{e}```")
|
||||
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# /json-history
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
@commands.slash_command(name="json-history", description="Historique des dernières générations automatiques et manuelles")
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def json_history(self, interaction: disnake.ApplicationCommandInteraction,
|
||||
limite: int = commands.Param(5, description="Nombre d'entrées à afficher (défaut: 5, max: 10)")):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
limite = max(1, min(limite, 10))
|
||||
history = get_history()
|
||||
|
||||
embed = self._embed("📜 Historique des générations JSON", disnake.Color.blurple())
|
||||
|
||||
if not history:
|
||||
embed.description = "_Aucune génération enregistrée pour l'instant._"
|
||||
else:
|
||||
for entry in history[:limite]:
|
||||
date_str = datetime.fromisoformat(entry["date"]).strftime("%d/%m/%Y %H:%M")
|
||||
trigger = "🤖 Auto" if entry.get("trigger") == "auto" else "👤 Manuel"
|
||||
status = "✅" if entry.get("success") else "⚠️"
|
||||
|
||||
lines = [f"{trigger} — {date_str}"]
|
||||
if entry.get("serversCount") is not None:
|
||||
lines.append(f"📋 {entry['serversCount']} serveurs")
|
||||
if entry.get("membersCount") is not None:
|
||||
lines.append(f"👥 {entry['membersCount']} membres ({entry.get('guildName', '?')})")
|
||||
if entry.get("errors"):
|
||||
lines.append(f"❌ Erreurs: {'; '.join(entry['errors'])}")
|
||||
|
||||
embed.add_field(
|
||||
name=f"{status} Génération du {date_str}",
|
||||
value="\n".join(lines),
|
||||
inline=False,
|
||||
)
|
||||
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
|
||||
# ─── Setup ───────────────────────────────────────────────────────────────────
|
||||
def setup(bot):
|
||||
bot.add_cog(WebsiteSync(bot))
|
||||
logger.info("Cog WebsiteSync chargé.")
|
||||
|
|
@ -1,18 +1,22 @@
|
|||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
import disnake
|
||||
from disnake.ext import commands
|
||||
import sqlite3
|
||||
import aiohttp
|
||||
import io
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
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):
|
||||
|
|
@ -42,103 +46,274 @@ class Welcome(commands.Cog):
|
|||
if 'welcome_channel_message' not in columns:
|
||||
cursor.execute('ALTER TABLE server_configs ADD COLUMN welcome_channel_message TEXT')
|
||||
|
||||
# Migration: Conversion des chemins absolus en chemins relatifs pour les bannières
|
||||
cursor.execute("SELECT server_id, welcome_banner_background FROM server_configs WHERE welcome_banner_background IS NOT NULL")
|
||||
for server_id, path in cursor.fetchall():
|
||||
if path and os.path.isabs(path):
|
||||
filename = os.path.basename(path)
|
||||
# Vérifier si c'est bien une bannière dans le dossier banners
|
||||
if "banner_" in filename:
|
||||
cursor.execute("UPDATE server_configs SET welcome_banner_background = ? WHERE server_id = ?", (filename, server_id))
|
||||
|
||||
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
|
||||
if banner_background and os.path.exists(banner_background):
|
||||
background = Image.open(banner_background)
|
||||
else:
|
||||
background = Image.open(os.path.join(base_dir, 'background_template.png'))
|
||||
# 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)
|
||||
if os.path.isabs(banner_background) and os.path.exists(banner_background):
|
||||
bg_path = banner_background
|
||||
else:
|
||||
# Sinon on cherche dans le dossier banners/
|
||||
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 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()
|
||||
async def on_member_join(self, member):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT welcome_channel, welcome_dm_message, welcome_server_name, welcome_banner_background, welcome_channel_message FROM server_configs WHERE server_id = ?', (member.guild.id,))
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
welcome_channel = result[0] if result else None
|
||||
welcome_dm_text = result[1] if result and result[1] else None
|
||||
server_name = result[2] if result and result[2] else None
|
||||
banner_background = result[3] if result and result[3] else None
|
||||
welcome_channel_message = result[4] if result and result[4] else None
|
||||
kuby_logger.info(f"📥 [Welcome] Événement on_member_join reçu pour {member.name} (ID: {member.id}) sur {member.guild.name}")
|
||||
|
||||
# 1. Gestion du DM
|
||||
if welcome_dm_text:
|
||||
try:
|
||||
formatted_message = welcome_dm_text.format(
|
||||
username=member.name,
|
||||
servername=member.guild.name,
|
||||
member_count=member.guild.member_count
|
||||
)
|
||||
await member.send(formatted_message)
|
||||
await member.send("https://i.giphy.com/media/v1.Y2lkPTc5MGI3NjExNXV1NW02NTk0bGF2ZHcyMGFna2F6amRrbGpobGpsMHowOW5xa2E5eCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/ZtFuW4rsUbfrRzPauJ/giphy.gif")
|
||||
except Exception as e:
|
||||
logging.warning(f"Impossible d'envoyer le DM à {member.name}: {e}")
|
||||
welcome_channel = None
|
||||
self._init_db()
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT welcome_channel, welcome_dm_message, welcome_server_name, welcome_banner_background, welcome_channel_message FROM server_configs WHERE server_id = ?', (member.guild.id,))
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
# 2. Gestion du salon de bienvenue
|
||||
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:
|
||||
# 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. <a:sip:1316891821858619452>"
|
||||
|
||||
embed = discord.Embed(title=f"Merci d'accueillir {member} 🤝",
|
||||
description=channel_description,
|
||||
color=discord.Color.dark_purple())
|
||||
embed.set_image(url="attachment://welcome.png")
|
||||
await channel.send(content=member.mention, embed=embed, file=discord.File(img, filename="welcome.png"))
|
||||
if not result:
|
||||
kuby_logger.debug(f"⚠️ [Welcome] Aucune configuration trouvée dans la base de données pour le serveur {member.guild.id}")
|
||||
return
|
||||
|
||||
async def save_banner_attachment(self, attachment: discord.Attachment, guild_id: int) -> str:
|
||||
welcome_channel = result[0]
|
||||
welcome_dm_text = result[1]
|
||||
server_name = result[2]
|
||||
banner_background = result[3]
|
||||
welcome_channel_message = result[4]
|
||||
|
||||
kuby_logger.debug(f"🔍 [Welcome] Config récupérée: DM={bool(welcome_dm_text)}, Channel={welcome_channel}")
|
||||
|
||||
# 1. Gestion du DM
|
||||
if welcome_dm_text and not member.bot:
|
||||
kuby_logger.info(f"✉️ [Welcome] Tentative d'envoi de DM à {member.name}")
|
||||
try:
|
||||
template_values = {
|
||||
# Alias populaires
|
||||
"user": member.mention,
|
||||
"username": member.name,
|
||||
"member": member.mention,
|
||||
"member_name": member.name,
|
||||
"server": member.guild.name,
|
||||
"servername": member.guild.name,
|
||||
"guild": member.guild.name,
|
||||
"guild_name": member.guild.name,
|
||||
"member_count": member.guild.member_count,
|
||||
"count": member.guild.member_count,
|
||||
"members": member.guild.member_count,
|
||||
}
|
||||
|
||||
class SafeDict(dict):
|
||||
def __missing__(self, key):
|
||||
return "{" + key + "}"
|
||||
|
||||
formatted_message = welcome_dm_text.format_map(SafeDict(template_values))
|
||||
|
||||
# On combine le texte et le GIF dans un Embed élégant pour masquer le lien brut
|
||||
gif_url = "https://i.giphy.com/media/v1.Y2lkPTc5MGI3NjExNXV1NW02NTk0bGF2ZHcyMGFna2F6amRrbGpobGpsMHowOW5xa2E5eCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/ZtFuW4rsUbfrRzPauJ/giphy.gif"
|
||||
embed = disnake.Embed(
|
||||
description=formatted_message,
|
||||
color=0x2b2d31 # Couleur sombre moderne et épurée
|
||||
)
|
||||
embed.set_image(url=gif_url)
|
||||
await member.send(embed=embed)
|
||||
kuby_logger.info(f"✅ [Welcome] DM envoyé avec succès à {member.name} sous forme d'Embed")
|
||||
except disnake.Forbidden:
|
||||
kuby_logger.warning(f"🚫 [Welcome] Impossible d'envoyer le DM à {member.name} (DMs fermés)")
|
||||
except disnake.HTTPException as e:
|
||||
if e.code == 40003: # Rate limited (Opening DMs too fast)
|
||||
kuby_logger.warning(f"⏳ [Welcome] Limite de débit Discord atteinte pour les DMs : {member.name} n'a pas reçu son message.")
|
||||
elif e.code == 50007: # Cannot send messages to this user
|
||||
kuby_logger.warning(f"🚫 [Welcome] Impossible d'envoyer le DM à {member.name} (DMs fermés ou utilisateur non joignable)")
|
||||
else:
|
||||
kuby_logger.error(f"❌ [Welcome] Erreur HTTP lors de l'envoi du DM à {member.name}: {e}")
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"❌ [Welcome] Erreur lors de l'envoi du DM à {member.name}: {e}", exc_info=True)
|
||||
else:
|
||||
kuby_logger.info(f"ℹ️ [Welcome] Aucun message de DM configuré pour le serveur {member.guild.id}")
|
||||
|
||||
# 2. Gestion du salon de bienvenue
|
||||
if welcome_channel:
|
||||
channel = member.guild.get_channel(welcome_channel)
|
||||
if channel:
|
||||
try:
|
||||
# Créer l'image de bienvenue
|
||||
server_display_name = server_name if server_name else member.guild.name
|
||||
img = await self.create_welcome_image(
|
||||
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:
|
||||
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 = ""
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
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}")
|
||||
except Exception as e:
|
||||
kuby_logger.error(f"❌ [Welcome] Erreur critique dans on_member_join: {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"""
|
||||
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "banners"))
|
||||
os.makedirs(base_dir, exist_ok=True)
|
||||
|
|
@ -168,37 +343,80 @@ class Welcome(commands.Cog):
|
|||
with open(banner_path, 'wb') as f:
|
||||
f.write(img_data)
|
||||
|
||||
return banner_path
|
||||
return os.path.basename(banner_path)
|
||||
|
||||
@app_commands.command(name="setwelcome", description="Définit le salon, les messages de bienvenue et le fond de la bannière")
|
||||
@app_commands.checks.has_permissions(administrator=True)
|
||||
async def set_welcome(self, interaction: discord.Interaction, channel: discord.TextChannel, message_dm: str, message_salon: str, server_name: str, banner_file: discord.Attachment = None):
|
||||
@commands.slash_command(name="setwelcome", description="Définit le salon, les messages de bienvenue et le fond de la bannière")
|
||||
@check_premium_tier()
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def set_welcome(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel, message_dm: str, message_salon: str, server_name: str, banner_file: disnake.Attachment = None):
|
||||
# ✅ DÉFÉRER LA RÉPONSE (le traitement peut prendre > 3s avec les images)
|
||||
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)
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(Welcome(bot))
|
||||
@commands.slash_command(name="invitations", description="Affiche vos statistiques d'invitations")
|
||||
@check_premium_tier()
|
||||
async def invitations_stats(self, interaction: disnake.ApplicationCommandInteraction, user: disnake.Member = None):
|
||||
user = user or interaction.author
|
||||
await self._send_invitation_stats(interaction, user)
|
||||
|
||||
@commands.user_command(name="Stats Invitations")
|
||||
async def invitations_user(self, interaction: disnake.UserCommandInteraction, user: disnake.Member):
|
||||
await self._send_invitation_stats(interaction, user)
|
||||
|
||||
async def _send_invitation_stats(self, interaction, user):
|
||||
invites_cog = self.bot.get_cog("Invites")
|
||||
if not invites_cog:
|
||||
return await interaction.response.send_message("❌ Le module d'invitations est désactivé.", ephemeral=True)
|
||||
|
||||
total, current = invites_cog.get_invite_stats(interaction.guild.id, user.id)
|
||||
|
||||
components = [
|
||||
disnake.ui.Container(
|
||||
disnake.ui.Section(
|
||||
f"Statistiques d'invitations - {user.display_name}",
|
||||
accessory=disnake.ui.Thumbnail(user.display_avatar.url)
|
||||
),
|
||||
disnake.ui.Separator(divider=True),
|
||||
disnake.ui.TextDisplay(f"➜ Total d'invitations : **{total}**"),
|
||||
disnake.ui.TextDisplay(f"➜ Membres présents : **{current}**")
|
||||
)
|
||||
]
|
||||
|
||||
await interaction.response.send_message(components=components)
|
||||
|
||||
def setup(bot):
|
||||
bot.add_cog(Welcome(bot))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
import disnake
|
||||
from disnake.ext import commands
|
||||
import json
|
||||
import os
|
||||
from typing import List
|
||||
from utils.premium import check_premium_tier
|
||||
|
||||
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")
|
||||
|
||||
class Whitelist(commands.GroupCog, name="whitelist"):
|
||||
# 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):
|
||||
self.bot = bot
|
||||
self.whitelist = self.load_whitelist()
|
||||
|
|
@ -32,58 +38,61 @@ class Whitelist(commands.GroupCog, name="whitelist"):
|
|||
self.whitelist[str(guild_id)] = users
|
||||
self.save_whitelist()
|
||||
|
||||
@commands.slash_command(name="whitelist", description="Gère la whitelist du serveur")
|
||||
@check_premium_tier()
|
||||
async def whitelist_group(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
"""Groupe de commandes pour gérer la whitelist"""
|
||||
pass
|
||||
|
||||
# ✅ Ajouter un utilisateur
|
||||
@app_commands.command(name="ajouter", description="Ajoute un utilisateur à la whitelist")
|
||||
@app_commands.describe(utilisateur="L'utilisateur à ajouter")
|
||||
async def ajouter(self, interaction: discord.Interaction, utilisateur: discord.User):
|
||||
@whitelist_group.sub_command(name="ajouter", description="Ajoute un utilisateur à la whitelist")
|
||||
async def ajouter(self, interaction: disnake.ApplicationCommandInteraction, utilisateur: disnake.User):
|
||||
guild_id = interaction.guild.id
|
||||
users = self._get_guild_whitelist(guild_id)
|
||||
user_id = str(utilisateur.id)
|
||||
|
||||
|
||||
if user_id in users:
|
||||
await interaction.response.send_message(f"🔒 {utilisateur.mention} est déjà dans la whitelist.", ephemeral=True)
|
||||
return
|
||||
|
||||
return await interaction.response.send_message(f"🔒 {utilisateur.mention} est déjà dans la whitelist.", ephemeral=True)
|
||||
|
||||
users.append(user_id)
|
||||
self._set_guild_whitelist(guild_id, users)
|
||||
await interaction.response.send_message(f"✅ {utilisateur.mention} a été ajouté à la whitelist.", ephemeral=True)
|
||||
|
||||
# ❌ Retirer un utilisateur
|
||||
@app_commands.command(name="retirer", description="Retire un utilisateur de la whitelist")
|
||||
@app_commands.describe(utilisateur="L'utilisateur à retirer")
|
||||
async def retirer(self, interaction: discord.Interaction, utilisateur: discord.User):
|
||||
@whitelist_group.sub_command(name="retirer", description="Retire un utilisateur de la whitelist")
|
||||
async def retirer(self, interaction: disnake.ApplicationCommandInteraction, utilisateur: disnake.User):
|
||||
guild_id = interaction.guild.id
|
||||
users = self._get_guild_whitelist(guild_id)
|
||||
user_id = str(utilisateur.id)
|
||||
|
||||
|
||||
if user_id not in users:
|
||||
await interaction.response.send_message(f"⚠️ {utilisateur.mention} n'est pas dans la whitelist.", ephemeral=True)
|
||||
return
|
||||
|
||||
return await interaction.response.send_message(f"⚠️ {utilisateur.mention} n'est pas dans la whitelist.", ephemeral=True)
|
||||
|
||||
users.remove(user_id)
|
||||
self._set_guild_whitelist(guild_id, users)
|
||||
await interaction.response.send_message(f"✅ {utilisateur.mention} a été retiré de la whitelist.", ephemeral=True)
|
||||
|
||||
# 📋 Voir la whitelist
|
||||
@app_commands.command(name="liste", description="Affiche les utilisateurs de la whitelist")
|
||||
async def liste(self, interaction: discord.Interaction):
|
||||
@whitelist_group.sub_command(name="liste", description="Affiche les utilisateurs de la whitelist")
|
||||
async def liste(self, interaction: disnake.ApplicationCommandInteraction):
|
||||
guild_id = interaction.guild.id
|
||||
users = self._get_guild_whitelist(guild_id)
|
||||
|
||||
|
||||
if not users:
|
||||
await interaction.response.send_message("📭 La whitelist est vide pour ce serveur.", ephemeral=True)
|
||||
return
|
||||
|
||||
return await interaction.response.send_message("📭 La whitelist est vide pour ce serveur.", ephemeral=True)
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
membres = []
|
||||
for user_id in users:
|
||||
try:
|
||||
utilisateur = await self.bot.fetch_user(int(user_id))
|
||||
membres.append(f"- {utilisateur.mention} ({utilisateur.name}#{utilisateur.discriminator})")
|
||||
except discord.NotFound:
|
||||
membres.append(f"- {utilisateur.mention} ({utilisateur.name})")
|
||||
except disnake.NotFound:
|
||||
membres.append(f"- <@{user_id}> (Utilisateur introuvable)")
|
||||
|
||||
|
||||
texte = "\n".join(membres)
|
||||
await interaction.response.send_message(f"📃 **Utilisateurs whitelistés pour ce serveur :**\n{texte}", ephemeral=True)
|
||||
await interaction.edit_original_response(content=f"📃 **Utilisateurs whitelistés pour ce serveur :**\n{texte}")
|
||||
|
||||
def is_whitelisted(self, guild_id: int, user_id: int) -> bool:
|
||||
"""Check if a user is whitelisted for a specific guild."""
|
||||
|
|
@ -94,5 +103,5 @@ class Whitelist(commands.GroupCog, name="whitelist"):
|
|||
return self._get_guild_whitelist(guild_id).copy()
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(Whitelist(bot))
|
||||
def setup(bot):
|
||||
bot.add_cog(Whitelist(bot))
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import discord
|
||||
from discord.ext import commands
|
||||
from discord.ui import View, Button
|
||||
import disnake
|
||||
from disnake.ext import commands
|
||||
from disnake.ui import View, Button
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
|
@ -14,18 +14,9 @@ class WhitelistMonitor(commands.Cog):
|
|||
self.whitelist_file = "data/whitelist.json"
|
||||
self.persistent_views_added = False
|
||||
self.big_permissions = [
|
||||
discord.Permissions.administrator,
|
||||
discord.Permissions.manage_guild,
|
||||
discord.Permissions.manage_roles,
|
||||
discord.Permissions.manage_channels,
|
||||
discord.Permissions.kick_members,
|
||||
discord.Permissions.ban_members,
|
||||
discord.Permissions.manage_messages,
|
||||
discord.Permissions.moderate_members,
|
||||
discord.Permissions.manage_nicknames,
|
||||
discord.Permissions.manage_emojis,
|
||||
discord.Permissions.manage_webhooks,
|
||||
discord.Permissions.view_audit_log
|
||||
"administrator", "manage_guild", "manage_roles", "manage_channels",
|
||||
"kick_members", "ban_members", "manage_messages", "moderate_members",
|
||||
"manage_nicknames", "manage_emojis", "manage_webhooks", "view_audit_log"
|
||||
]
|
||||
|
||||
def load_whitelist(self) -> Dict[str, List[str]]:
|
||||
|
|
@ -70,15 +61,12 @@ class WhitelistMonitor(commands.Cog):
|
|||
return True
|
||||
return False
|
||||
|
||||
def has_big_permissions(self, role: discord.Role) -> bool:
|
||||
def has_big_permissions(self, role: disnake.Role) -> bool:
|
||||
"""Check if role has significant permissions."""
|
||||
permissions = role.permissions
|
||||
for perm in self.big_permissions:
|
||||
if permissions.administrator or getattr(permissions, perm.name, False):
|
||||
return True
|
||||
return False
|
||||
perms = role.permissions
|
||||
return any(getattr(perms, p, False) for p in self.big_permissions)
|
||||
|
||||
async def send_whitelist_notification(self, member: discord.Member, removed_roles: List[discord.Role]):
|
||||
async def send_whitelist_notification(self, member: disnake.Member, removed_roles: List[disnake.Role]):
|
||||
"""Send DM notification to user about whitelist member losing roles."""
|
||||
try:
|
||||
# Find guild administrators to notify
|
||||
|
|
@ -87,10 +75,10 @@ class WhitelistMonitor(commands.Cog):
|
|||
return
|
||||
|
||||
# Create embed
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title="⚠️ Whitelist Alert",
|
||||
description=f"Un membre whitelisté a perdu des rôles importants dans **{member.guild.name}**.",
|
||||
color=discord.Color.orange(),
|
||||
color=disnake.Color.orange(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
|
|
@ -121,7 +109,7 @@ class WhitelistMonitor(commands.Cog):
|
|||
for admin in admins:
|
||||
try:
|
||||
await admin.send(embed=embed, view=view)
|
||||
except discord.Forbidden:
|
||||
except disnake.Forbidden:
|
||||
continue # User has DMs disabled
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -155,7 +143,7 @@ class WhitelistMonitor(commands.Cog):
|
|||
print(f"[WhitelistMonitor] Error registering views: {e}")
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_update(self, before: discord.Member, after: discord.Member):
|
||||
async def on_member_update(self, before: disnake.Member, after: disnake.Member):
|
||||
"""Monitor role changes for whitelist members."""
|
||||
if before.roles == after.roles:
|
||||
return
|
||||
|
|
@ -173,7 +161,7 @@ class WhitelistMonitor(commands.Cog):
|
|||
await self.send_whitelist_notification(after, important_removed)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_remove(self, member: discord.Member):
|
||||
async def on_member_remove(self, member: disnake.Member):
|
||||
"""Auto-remove from whitelist when member leaves server."""
|
||||
if self.is_whitelisted(member.guild.id, member.id):
|
||||
removed = self.remove_from_whitelist(member.guild.id, member.id)
|
||||
|
|
@ -184,10 +172,10 @@ class WhitelistMonitor(commands.Cog):
|
|||
if log_channel_id:
|
||||
log_channel = member.guild.get_channel(log_channel_id)
|
||||
if log_channel:
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title="🗑️ Auto-Whitelist Removal",
|
||||
description=f"{member.mention} a été retiré automatiquement de la whitelist après avoir quitté le serveur.",
|
||||
color=discord.Color.red(),
|
||||
color=disnake.Color.red(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
await log_channel.send(embed=embed)
|
||||
|
|
@ -214,15 +202,15 @@ class WhitelistRemovalView(View):
|
|||
self.user_id = user_id
|
||||
self.guild_id = guild_id
|
||||
|
||||
@discord.ui.button(
|
||||
@disnake.ui.button(
|
||||
label="Retirer de la whitelist",
|
||||
style=discord.ButtonStyle.red,
|
||||
style=disnake.ButtonStyle.red,
|
||||
emoji="🗑️",
|
||||
custom_id="whitelist_remove_button"
|
||||
)
|
||||
async def remove_whitelist(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
interaction: disnake.Interaction,
|
||||
button: Button
|
||||
):
|
||||
"""Remove user from whitelist when button is clicked."""
|
||||
|
|
@ -250,15 +238,15 @@ class WhitelistRemovalView(View):
|
|||
# Update button
|
||||
button.disabled = True
|
||||
button.label = "✅ Retiré"
|
||||
button.style = discord.ButtonStyle.green
|
||||
button.style = disnake.ButtonStyle.green
|
||||
|
||||
await interaction.response.edit_message(view=self)
|
||||
|
||||
# Send confirmation
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title="✅ Retrait effectué",
|
||||
description=f"{user_mention} a été retiré de la whitelist.",
|
||||
color=discord.Color.green()
|
||||
color=disnake.Color.green()
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
|
|
@ -270,10 +258,10 @@ class WhitelistRemovalView(View):
|
|||
if log_channel_id:
|
||||
log_channel = guild.get_channel(log_channel_id)
|
||||
if log_channel:
|
||||
embed = discord.Embed(
|
||||
embed = disnake.Embed(
|
||||
title="🗑️ Whitelist Manual Removal",
|
||||
description=f"{user_mention} a été retiré manuellement de la whitelist.",
|
||||
color=discord.Color.green(),
|
||||
color=disnake.Color.green(),
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
embed.set_footer(text=f"Retiré par {interaction.user.display_name}")
|
||||
|
|
@ -293,5 +281,5 @@ class WhitelistRemovalView(View):
|
|||
ephemeral=True
|
||||
)
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(WhitelistMonitor(bot))
|
||||
def setup(bot):
|
||||
bot.add_cog(WhitelistMonitor(bot))
|
||||
|
|
|
|||
BIN
config.db
BIN
config.db
Binary file not shown.
9
config/whitelist_questions.json
Normal file
9
config/whitelist_questions.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"questions": [
|
||||
{"label": "Pseudo Epic (Fortnite)", "custom_id": "pseudo", "placeholder": "JohnDoe", "required": true, "style": "short", "max_length": 16},
|
||||
{"label": "Âge", "custom_id": "age", "placeholder": "18", "required": true, "style": "short", "max_length": 2},
|
||||
{"label": "Expérience RP ?", "custom_id": "experience", "placeholder": "Oui/Non", "required": true, "style": "paragraph", "max_length": 50},
|
||||
{"label": "Comment as‑tu découvert le serveur ?", "custom_id": "decouverte", "placeholder": "", "required": true, "style": "short", "max_length": 20},
|
||||
{"label": "As‑tu un micro de qualité ?", "custom_id": "micro", "placeholder": "", "required": true, "style": "short", "max_length": 3}
|
||||
]
|
||||
}
|
||||
10
data/absence_settings.json
Normal file
10
data/absence_settings.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"1369669999345537145": {
|
||||
"absence_channel_id": 1369669999677145103,
|
||||
"absence_role_id": 1369669999345537154
|
||||
},
|
||||
"1402491843899555840": {
|
||||
"absence_channel_id": 1494492360732835972,
|
||||
"absence_role_id": 1501619311918190703
|
||||
}
|
||||
}
|
||||
4
data/absences.json
Normal file
4
data/absences.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"1369669999345537145": {},
|
||||
"1402491843899555840": {}
|
||||
}
|
||||
13
data/convocation_backups.json
Normal file
13
data/convocation_backups.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"1369669999345537145_1337733859814543410": [
|
||||
1370452086193655840
|
||||
],
|
||||
"1369669999345537145_1508391404831903836": {
|
||||
"roles": [],
|
||||
"overrides": {
|
||||
"1510558364420149310": {
|
||||
"view_channel": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
data/convocation_settings.json
Normal file
11
data/convocation_settings.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"1369669999345537145": {
|
||||
"role_id": 1369669999366770723,
|
||||
"log_channel_id": 1369669999677145103,
|
||||
"admin_ids": [
|
||||
353942721997832195,
|
||||
971446412690722826
|
||||
],
|
||||
"attente_id": 1369669999677145099
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
{
|
||||
"4": 971446412690722826,
|
||||
"5": 971446412690722826,
|
||||
"11": 971446412690722826
|
||||
"15": 971446412690722826,
|
||||
"153": 534003230435442690,
|
||||
"160": 971446412690722826,
|
||||
"161": 971446412690722826,
|
||||
"162": 971446412690722826,
|
||||
"164": 971446412690722826
|
||||
}
|
||||
37
data/json-export-config.json
Normal file
37
data/json-export-config.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"targetGuildId": "1369669999345537145",
|
||||
"autoHour": 3,
|
||||
"autoMinute": 0,
|
||||
"outputDir": "/home/gameurpro12/Documents/Mes_projets/Site_Omega_Kube/website/assets/data",
|
||||
"founderRoles": [
|
||||
"fondateur",
|
||||
"founder"
|
||||
],
|
||||
"serverFields": {
|
||||
"id": true,
|
||||
"name": true,
|
||||
"icon": true,
|
||||
"banner": true,
|
||||
"owner": true,
|
||||
"memberCount": true,
|
||||
"description": true,
|
||||
"createdAt": true,
|
||||
"boostLevel": false,
|
||||
"boostCount": false,
|
||||
"verificationLevel": false
|
||||
},
|
||||
"memberFields": {
|
||||
"id": true,
|
||||
"username": true,
|
||||
"displayName": true,
|
||||
"globalName": true,
|
||||
"avatar": true,
|
||||
"bio": true,
|
||||
"roles": true,
|
||||
"joinedAt": true,
|
||||
"createdAt": true,
|
||||
"badges": false,
|
||||
"accentColor": false,
|
||||
"isBot": false
|
||||
}
|
||||
}
|
||||
182
data/json-export-history.json
Normal file
182
data/json-export-history.json
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
[
|
||||
{
|
||||
"date": "2026-05-15T21:49:37.114321+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:44:14.212871+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:42:45.960991+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:40:28.754282+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:39:55.113067+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:37:43.517923+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:36:48.733530+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:33:22.503958+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:31:58.777625+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:28:24.375077+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:27:00.933212+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:24:34.075332+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:23:28.940963+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:22:50.796880+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:21:47.335364+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:20:45.322710+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:19:02.759168+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T21:16:56.391804+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15T20:50:21.727551+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 13,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"date": "2026-05-13T16:47:42.624873+00:00",
|
||||
"trigger": "auto",
|
||||
"serversCount": 1,
|
||||
"membersCount": 12,
|
||||
"guildName": "Omega Kube Bêta Test",
|
||||
"errors": [],
|
||||
"success": true
|
||||
}
|
||||
]
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2025-08-19T14:50:31.191194",
|
||||
"event_type": "member_join",
|
||||
"member_id": 318312854816161792,
|
||||
"member_name": "DraftBot#0535",
|
||||
"member_discriminator": "0535",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/318312854816161792/f1d4dc93441273f3f8985a07e0bad164.png?size=1024"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-19T14:51:48.780957",
|
||||
"event_type": "member_leave",
|
||||
"member_id": 828239898975666216,
|
||||
"member_name": "docteur_w.d_axolotl",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/828239898975666216/1835a7eaede10e9dbb78649beeed070c.png?size=1024",
|
||||
"roles": {
|
||||
"role_ids": [],
|
||||
"role_names": [],
|
||||
"permissions": []
|
||||
},
|
||||
"joined_at": "2025-05-09T18:36:32.808000+00:00"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-19T14:58:16.673562",
|
||||
"event_type": "member_leave",
|
||||
"member_id": 318312854816161792,
|
||||
"member_name": "DraftBot#0535",
|
||||
"member_discriminator": "0535",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/318312854816161792/f1d4dc93441273f3f8985a07e0bad164.png?size=1024",
|
||||
"roles": {
|
||||
"role_ids": [
|
||||
1403041729330024508
|
||||
],
|
||||
"role_names": [
|
||||
"🗝️"
|
||||
],
|
||||
"permissions": [
|
||||
8
|
||||
]
|
||||
},
|
||||
"joined_at": "2025-08-19T12:50:31.007000+00:00"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-19T14:59:06.458094",
|
||||
"event_type": "member_leave",
|
||||
"member_id": 353942721997832195,
|
||||
"member_name": "mg_studios_",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/353942721997832195/99c6b7514ce56e19cf87b2c8f0d41316.png?size=1024",
|
||||
"roles": {
|
||||
"role_ids": [
|
||||
1396130252144508939,
|
||||
1369669999366770724,
|
||||
1369669999366770726
|
||||
],
|
||||
"role_names": [
|
||||
"mg_studios_",
|
||||
"👔 | Co-Directeur",
|
||||
"🔑"
|
||||
],
|
||||
"permissions": [
|
||||
0,
|
||||
0,
|
||||
8
|
||||
]
|
||||
},
|
||||
"joined_at": "2025-05-07T13:41:48.145000+00:00"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-19T14:59:19.218122",
|
||||
"event_type": "member_join",
|
||||
"member_id": 353942721997832195,
|
||||
"member_name": "mg_studios_",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/353942721997832195/99c6b7514ce56e19cf87b2c8f0d41316.png?size=1024"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-19T21:41:25.723957",
|
||||
"event_type": "member_join",
|
||||
"member_id": 1407448905817391215,
|
||||
"member_name": "test_33878",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": null
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-19T23:48:12.347482",
|
||||
"event_type": "member_leave",
|
||||
"member_id": 1407448905817391215,
|
||||
"member_name": "test_33878",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": null,
|
||||
"roles": {
|
||||
"role_ids": [],
|
||||
"role_names": [],
|
||||
"permissions": []
|
||||
},
|
||||
"joined_at": "2025-08-19T19:41:25.391607+00:00"
|
||||
}
|
||||
]
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2025-08-20T00:40:57.820048",
|
||||
"event_type": "member_join",
|
||||
"member_id": 1407448905817391215,
|
||||
"member_name": "test_33878",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": null
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-20T00:42:00.543535",
|
||||
"event_type": "member_leave",
|
||||
"member_id": 1407448905817391215,
|
||||
"member_name": "test_33878",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": null,
|
||||
"roles": {
|
||||
"role_ids": [],
|
||||
"role_names": [],
|
||||
"permissions": []
|
||||
},
|
||||
"joined_at": "2025-08-19T22:40:57.711576+00:00"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-20T00:47:10.265057",
|
||||
"event_type": "member_join",
|
||||
"member_id": 1407448905817391215,
|
||||
"member_name": "test_33878",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": null
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-20T00:47:23.923297",
|
||||
"event_type": "member_leave",
|
||||
"member_id": 1407448905817391215,
|
||||
"member_name": "test_33878",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": null,
|
||||
"roles": {
|
||||
"role_ids": [],
|
||||
"role_names": [],
|
||||
"permissions": []
|
||||
},
|
||||
"joined_at": "2025-08-19T22:47:10.020304+00:00"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-20T12:24:08.056140",
|
||||
"event_type": "member_leave",
|
||||
"member_id": 1407670411164127242,
|
||||
"member_name": "test_109375",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": null,
|
||||
"roles": {
|
||||
"role_ids": [],
|
||||
"role_names": [],
|
||||
"permissions": []
|
||||
},
|
||||
"joined_at": "2025-08-20T10:21:05.730620+00:00"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-20T12:27:27.999521",
|
||||
"event_type": "member_join",
|
||||
"member_id": 1407670411164127242,
|
||||
"member_name": "test_109375",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": null
|
||||
}
|
||||
]
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2025-08-22T19:06:00.143653",
|
||||
"event_type": "member_leave",
|
||||
"member_id": 872192738920108074,
|
||||
"member_name": "titis_zfr_7007",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/872192738920108074/986a18e279578123ec855f63defcc23f.png?size=1024",
|
||||
"roles": {
|
||||
"role_ids": [
|
||||
1369769836028231730
|
||||
],
|
||||
"role_names": [
|
||||
"⚒️ | Bêta Testeurs"
|
||||
],
|
||||
"permissions": [
|
||||
1032
|
||||
]
|
||||
},
|
||||
"joined_at": "2025-07-18T21:59:11.510000+00:00"
|
||||
}
|
||||
]
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2025-12-30T01:03:48.950150",
|
||||
"event_type": "member_join",
|
||||
"member_id": 1003956683040624672,
|
||||
"member_name": "eva56_",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/1003956683040624672/c364667c914a821cd24212d32f7762b8.png?size=1024"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T01:04:52.974501",
|
||||
"event_type": "member_join",
|
||||
"member_id": 1013376581458210986,
|
||||
"member_name": "kayrixtv",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/1013376581458210986/42d93e13065339ae40c50bcbf7a7b667.png?size=1024"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T01:05:11.496294",
|
||||
"event_type": "member_leave",
|
||||
"member_id": 1003956683040624672,
|
||||
"member_name": "eva56_",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/1003956683040624672/c364667c914a821cd24212d32f7762b8.png?size=1024",
|
||||
"roles": {
|
||||
"role_ids": [],
|
||||
"role_names": [],
|
||||
"permissions": []
|
||||
},
|
||||
"joined_at": "2025-12-30T00:03:48.810667+00:00"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T01:05:38.027616",
|
||||
"event_type": "member_join",
|
||||
"member_id": 1003956683040624672,
|
||||
"member_name": "eva56_",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/1003956683040624672/c364667c914a821cd24212d32f7762b8.png?size=1024"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T01:06:16.615009",
|
||||
"event_type": "member_leave",
|
||||
"member_id": 1003956683040624672,
|
||||
"member_name": "eva56_",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/1003956683040624672/c364667c914a821cd24212d32f7762b8.png?size=1024",
|
||||
"roles": {
|
||||
"role_ids": [],
|
||||
"role_names": [],
|
||||
"permissions": []
|
||||
},
|
||||
"joined_at": "2025-12-30T00:05:37.900874+00:00"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T01:06:36.640980",
|
||||
"event_type": "member_join",
|
||||
"member_id": 1003956683040624672,
|
||||
"member_name": "eva56_",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/1003956683040624672/c364667c914a821cd24212d32f7762b8.png?size=1024"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T01:09:36.950883",
|
||||
"event_type": "member_leave",
|
||||
"member_id": 1003956683040624672,
|
||||
"member_name": "eva56_",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/1003956683040624672/c364667c914a821cd24212d32f7762b8.png?size=1024",
|
||||
"roles": {
|
||||
"role_ids": [],
|
||||
"role_names": [],
|
||||
"permissions": []
|
||||
},
|
||||
"joined_at": "2025-12-30T00:06:36.510813+00:00"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T01:13:30.386228",
|
||||
"event_type": "member_join",
|
||||
"member_id": 1003956683040624672,
|
||||
"member_name": "eva56_",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/1003956683040624672/c364667c914a821cd24212d32f7762b8.png?size=1024"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T01:13:47.629282",
|
||||
"event_type": "member_leave",
|
||||
"member_id": 1003956683040624672,
|
||||
"member_name": "eva56_",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/1003956683040624672/c364667c914a821cd24212d32f7762b8.png?size=1024",
|
||||
"roles": {
|
||||
"role_ids": [],
|
||||
"role_names": [],
|
||||
"permissions": []
|
||||
},
|
||||
"joined_at": "2025-12-30T00:13:30.252167+00:00"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T01:15:07.364595",
|
||||
"event_type": "member_join",
|
||||
"member_id": 1003956683040624672,
|
||||
"member_name": "eva56_",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/1003956683040624672/c364667c914a821cd24212d32f7762b8.png?size=1024"
|
||||
}
|
||||
]
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2026-01-01T22:00:26.255907",
|
||||
"event_type": "member_join",
|
||||
"member_id": 1456390746167967932,
|
||||
"member_name": "juibu_66388",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": null
|
||||
}
|
||||
]
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2026-01-03T21:25:17.730927",
|
||||
"event_type": "member_leave",
|
||||
"member_id": 1456390746167967932,
|
||||
"member_name": "juibu_66388",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": null,
|
||||
"roles": {
|
||||
"role_ids": [
|
||||
1369669999366770721
|
||||
],
|
||||
"role_names": [
|
||||
"Faux banquier"
|
||||
],
|
||||
"permissions": [
|
||||
8192
|
||||
]
|
||||
},
|
||||
"joined_at": "2026-01-01T21:00:26.074000+00:00"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-03T21:25:28.894683",
|
||||
"event_type": "member_join",
|
||||
"member_id": 1456390746167967932,
|
||||
"member_name": "juibu_66388",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": null
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-03T21:49:33.975462",
|
||||
"event_type": "member_leave",
|
||||
"member_id": 1456390746167967932,
|
||||
"member_name": "juibu_66388",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": null,
|
||||
"roles": {
|
||||
"role_ids": [
|
||||
1369669999366770721
|
||||
],
|
||||
"role_names": [
|
||||
"Faux banquier"
|
||||
],
|
||||
"permissions": [
|
||||
8192
|
||||
]
|
||||
},
|
||||
"joined_at": "2026-01-03T20:25:28.737000+00:00"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-03T21:49:55.375389",
|
||||
"event_type": "member_join",
|
||||
"member_id": 1456390746167967932,
|
||||
"member_name": "juibu_66388",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": null
|
||||
}
|
||||
]
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2026-01-10T16:19:17.701722",
|
||||
"event_type": "member_join",
|
||||
"member_id": 1459567012031234198,
|
||||
"member_name": "fequjifenq_85720",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": null
|
||||
}
|
||||
]
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2026-01-24T15:13:01.488356",
|
||||
"event_type": "member_join",
|
||||
"member_id": 1038035891450556507,
|
||||
"member_name": "celness",
|
||||
"member_discriminator": "0",
|
||||
"member_avatar": "https://cdn.discordapp.com/avatars/1038035891450556507/e57596b7be5db3a5f3baf75f5fd4720b.png?size=1024"
|
||||
}
|
||||
]
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2025-08-15T17:03:39.778010",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1405929928641351842,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 1324439906046574693,
|
||||
"author_name": "_radio_demon77_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1403041729330024508
|
||||
],
|
||||
"content": "test",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T17:04:15.541259",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1405930092764336172,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 1324439906046574693,
|
||||
"author_name": "_radio_demon77_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1403041729330024508
|
||||
],
|
||||
"content": "bl",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T17:04:26.128394",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1405930138096635996,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 353942721997832195,
|
||||
"author_name": "mg_studios_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396130252144508939,
|
||||
1369669999366770724,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "fe",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T17:04:26.581875",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1405930139358859386,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 353942721997832195,
|
||||
"author_name": "mg_studios_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396130252144508939,
|
||||
1369669999366770724,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "fe",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T17:04:27.110643",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1405930141888282686,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 353942721997832195,
|
||||
"author_name": "mg_studios_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396130252144508939,
|
||||
1369669999366770724,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "fe",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T17:04:27.420456",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1405930143549100153,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 353942721997832195,
|
||||
"author_name": "mg_studios_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396130252144508939,
|
||||
1369669999366770724,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "fef",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T17:04:27.895168",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1405930145155514388,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 353942721997832195,
|
||||
"author_name": "mg_studios_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396130252144508939,
|
||||
1369669999366770724,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "ef",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T17:04:55.151271",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1405930245793517729,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 1324439906046574693,
|
||||
"author_name": "_radio_demon77_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1403041729330024508
|
||||
],
|
||||
"content": "carré pour ça",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T17:07:29.586201",
|
||||
"event_type": "edited",
|
||||
"message_id": 1405930871201988708,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 353942721997832195,
|
||||
"author_name": "mg_studios_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396130252144508939,
|
||||
1369669999366770724,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "il est pas kick du serv juste il peux plus écrire",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {
|
||||
"before_content": "il est pas kick du serv juste il peux plus écrire",
|
||||
"after_content": "il est pas kick du serv juste il peut plus écrire"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T17:08:24.314935",
|
||||
"event_type": "edited",
|
||||
"message_id": 1405931135254528171,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 1324439906046574693,
|
||||
"author_name": "_radio_demon77_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1403041729330024508
|
||||
],
|
||||
"content": "[fae](https://cdn.discordapp.com/emojis/1377581019649151077.webp?size=48&name=fae)",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {
|
||||
"before_content": "[fae](https://cdn.discordapp.com/emojis/1377581019649151077.webp?size=48&name=fae)",
|
||||
"after_content": "[fae](https://cdn.discordapp.com/emojis/1377581019649151077.webp?size=48&name=fae)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T17:51:19.548967",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1405941938007314614,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 1324439906046574693,
|
||||
"author_name": "_radio_demon77_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1403041729330024508
|
||||
],
|
||||
"content": "k",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T17:51:20.415679",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1405941942227046552,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 1324439906046574693,
|
||||
"author_name": "_radio_demon77_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1403041729330024508
|
||||
],
|
||||
"content": "k",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T17:52:26.716628",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1405942220049219737,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 1324439906046574693,
|
||||
"author_name": "_radio_demon77_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1403041729330024508
|
||||
],
|
||||
"content": "<@353942721997832195> il me faut qqn de pas op pour tester le spam",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2025-08-16T17:11:20.495657",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1406294262861140100,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 1324439906046574693,
|
||||
"author_name": "_radio_demon77_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1403041729330024508
|
||||
],
|
||||
"content": "ou @here",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T17:22:10.841132",
|
||||
"event_type": "edited",
|
||||
"message_id": 1406296949967290461,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 1324439906046574693,
|
||||
"author_name": "_radio_demon77_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1403041729330024508
|
||||
],
|
||||
"content": "je dois yalelr en plus",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {
|
||||
"before_content": "je dois yalelr en plus",
|
||||
"after_content": "je dois y aller en plus"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2025-08-17T13:14:10.888477",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1406596967433244725,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 1324439906046574693,
|
||||
"author_name": "_radio_demon77_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1403041729330024508
|
||||
],
|
||||
"content": "h",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-17T13:14:11.627298",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1406596971757568060,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 1324439906046574693,
|
||||
"author_name": "_radio_demon77_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1403041729330024508
|
||||
],
|
||||
"content": "h",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-17T13:14:11.996001",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1406596972982308935,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 1324439906046574693,
|
||||
"author_name": "_radio_demon77_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1403041729330024508
|
||||
],
|
||||
"content": "h",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-17T13:14:12.397997",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1406596974714818569,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 1324439906046574693,
|
||||
"author_name": "_radio_demon77_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1403041729330024508
|
||||
],
|
||||
"content": "h",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-17T13:15:46.771221",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1406597370661310564,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 1324439906046574693,
|
||||
"author_name": "_radio_demon77_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1403041729330024508
|
||||
],
|
||||
"content": "h",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,303 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2025-08-19T14:31:07.894788",
|
||||
"event_type": "edited",
|
||||
"message_id": 1407341109302267986,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396112611292090368,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "https://music.youtube.com/watch?v=ZjHjIvlokkg&list=RDAMVMZjHjIvlokkg",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {
|
||||
"before_content": "https://music.youtube.com/watch?v=ZjHjIvlokkg&list=RDAMVMZjHjIvlokkg",
|
||||
"after_content": "https://music.youtube.com/watch?v=ZjHjIvlokkg&list=RDAMVMZjHjIvlokkg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-19T14:31:07.908824",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1407341109302267986,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396112611292090368,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "https://music.youtube.com/watch?v=ZjHjIvlokkg&list=RDAMVMZjHjIvlokkg",
|
||||
"attachments": [],
|
||||
"embeds": [
|
||||
{
|
||||
"thumbnail": {
|
||||
"width": 1280,
|
||||
"url": "https://i.ytimg.com/vi/ZjHjIvlokkg/maxresdefault.jpg",
|
||||
"proxy_url": "https://images-ext-1.discordapp.net/external/rqEye0Kbh537345_yiwGFQmGsNDfxFg10JHdnuiizrI/https/i.ytimg.com/vi/ZjHjIvlokkg/maxresdefault.jpg",
|
||||
"placeholder_version": 1,
|
||||
"placeholder": "BQgGDIIIplqHd3h4iIUIfHXzJw==",
|
||||
"height": 720,
|
||||
"flags": 0,
|
||||
"content_type": "image/jpeg"
|
||||
},
|
||||
"video": {
|
||||
"width": 1280,
|
||||
"url": "https://www.youtube.com/embed/ZjHjIvlokkg",
|
||||
"placeholder_version": 1,
|
||||
"placeholder": "BQgGDIIIplqHd3h4iIUIfHXzJw==",
|
||||
"height": 720,
|
||||
"flags": 0
|
||||
},
|
||||
"provider": {
|
||||
"url": "https://www.youtube.com",
|
||||
"name": "YouTube"
|
||||
},
|
||||
"author": {
|
||||
"url": "https://www.youtube.com/channel/UCQgovvTTLPxAazVQE6X2ivQ",
|
||||
"name": "Fredz"
|
||||
},
|
||||
"color": 16711680,
|
||||
"type": "video",
|
||||
"description": "Les belles filles - Fredz ft. Leslie Medina\nlive au Badaboum à PARIS le 16/12/22\nJ'espère que ça vous plaira ! Merci pour tout\n\nRéalisation: Noémie Fray (@nomzoa)\nÉtalonnage: Stéphane Martins (@smartins_dop)\n\nRetrouvez Fredz sur :\nInstagram : https://www.instagram.com/fredz_musicc/\nTik Tok : https://vm.tiktok.com/ZSv9b5US/\nFacebook : http...",
|
||||
"url": "https://www.youtube.com/watch?v=ZjHjIvlokkg",
|
||||
"title": "FREDZ feat. Leslie Medina - Les belles filles (Live à Paris)"
|
||||
}
|
||||
],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-19T14:31:25.362973",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1407341171679826030,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396112611292090368,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "https://music.youtube.com/watch?v=ZjHjIvlokkg&list=RDAMVMZjHjIvlokkg",
|
||||
"attachments": [],
|
||||
"embeds": [
|
||||
{
|
||||
"thumbnail": {
|
||||
"width": 1280,
|
||||
"url": "https://i.ytimg.com/vi/ZjHjIvlokkg/maxresdefault.jpg",
|
||||
"proxy_url": "https://images-ext-1.discordapp.net/external/rqEye0Kbh537345_yiwGFQmGsNDfxFg10JHdnuiizrI/https/i.ytimg.com/vi/ZjHjIvlokkg/maxresdefault.jpg",
|
||||
"placeholder_version": 1,
|
||||
"placeholder": "BQgGDIIIplqHd3h4iIUIfHXzJw==",
|
||||
"height": 720,
|
||||
"flags": 0,
|
||||
"content_type": "image/jpeg"
|
||||
},
|
||||
"video": {
|
||||
"width": 1280,
|
||||
"url": "https://www.youtube.com/embed/ZjHjIvlokkg",
|
||||
"placeholder_version": 1,
|
||||
"placeholder": "BQgGDIIIplqHd3h4iIUIfHXzJw==",
|
||||
"height": 720,
|
||||
"flags": 0
|
||||
},
|
||||
"provider": {
|
||||
"url": "https://www.youtube.com",
|
||||
"name": "YouTube"
|
||||
},
|
||||
"author": {
|
||||
"url": "https://www.youtube.com/channel/UCQgovvTTLPxAazVQE6X2ivQ",
|
||||
"name": "Fredz"
|
||||
},
|
||||
"color": 16711680,
|
||||
"type": "video",
|
||||
"description": "Les belles filles - Fredz ft. Leslie Medina\nlive au Badaboum à PARIS le 16/12/22\nJ'espère que ça vous plaira ! Merci pour tout\n\nRéalisation: Noémie Fray (@nomzoa)\nÉtalonnage: Stéphane Martins (@smartins_dop)\n\nRetrouvez Fredz sur :\nInstagram : https://www.instagram.com/fredz_musicc/\nTik Tok : https://vm.tiktok.com/ZSv9b5US/\nFacebook : http...",
|
||||
"url": "https://www.youtube.com/watch?v=ZjHjIvlokkg",
|
||||
"title": "FREDZ feat. Leslie Medina - Les belles filles (Live à Paris)"
|
||||
}
|
||||
],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-19T14:31:30.378149",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1407341203208278036,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396112611292090368,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "https://music.youtube.com/watch?v=ZjHjIvlokkg&list=RDAMVMZjHjIvlokkg",
|
||||
"attachments": [],
|
||||
"embeds": [
|
||||
{
|
||||
"thumbnail": {
|
||||
"width": 1280,
|
||||
"url": "https://i.ytimg.com/vi/ZjHjIvlokkg/maxresdefault.jpg",
|
||||
"proxy_url": "https://images-ext-1.discordapp.net/external/rqEye0Kbh537345_yiwGFQmGsNDfxFg10JHdnuiizrI/https/i.ytimg.com/vi/ZjHjIvlokkg/maxresdefault.jpg",
|
||||
"placeholder_version": 1,
|
||||
"placeholder": "BQgGDIIIplqHd3h4iIUIfHXzJw==",
|
||||
"height": 720,
|
||||
"flags": 0,
|
||||
"content_type": "image/jpeg"
|
||||
},
|
||||
"video": {
|
||||
"width": 1280,
|
||||
"url": "https://www.youtube.com/embed/ZjHjIvlokkg",
|
||||
"placeholder_version": 1,
|
||||
"placeholder": "BQgGDIIIplqHd3h4iIUIfHXzJw==",
|
||||
"height": 720,
|
||||
"flags": 0
|
||||
},
|
||||
"provider": {
|
||||
"url": "https://www.youtube.com",
|
||||
"name": "YouTube"
|
||||
},
|
||||
"author": {
|
||||
"url": "https://www.youtube.com/channel/UCQgovvTTLPxAazVQE6X2ivQ",
|
||||
"name": "Fredz"
|
||||
},
|
||||
"color": 16711680,
|
||||
"type": "video",
|
||||
"description": "Les belles filles - Fredz ft. Leslie Medina\nlive au Badaboum à PARIS le 16/12/22\nJ'espère que ça vous plaira ! Merci pour tout\n\nRéalisation: Noémie Fray (@nomzoa)\nÉtalonnage: Stéphane Martins (@smartins_dop)\n\nRetrouvez Fredz sur :\nInstagram : https://www.instagram.com/fredz_musicc/\nTik Tok : https://vm.tiktok.com/ZSv9b5US/\nFacebook : http...",
|
||||
"url": "https://www.youtube.com/watch?v=ZjHjIvlokkg",
|
||||
"title": "FREDZ feat. Leslie Medina - Les belles filles (Live à Paris)"
|
||||
}
|
||||
],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-19T20:00:33.051055",
|
||||
"event_type": "edited",
|
||||
"message_id": 1407423989206356008,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396112611292090368,
|
||||
1369669999366770723,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "Viens ici mo cobay préférer",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {
|
||||
"before_content": "Viens ici mo cobay préférer",
|
||||
"after_content": "Viens ici mon cobay préférer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-19T20:09:09.967269",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1407426178486374564,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396112611292090368,
|
||||
1369669999366770723,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "@everyone qui veut faire le cobay avec moi",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-19T22:19:00.115805",
|
||||
"event_type": "edited",
|
||||
"message_id": 1407458853196529705,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 353942721997832195,
|
||||
"author_name": "mg_studios_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396130252144508939,
|
||||
1369669999366770724
|
||||
],
|
||||
"content": "https://tenor.com/view/14th-doctor-doctor-who-david-tennant-wild-blue-yonder-rage-gif-12474509552290072827",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {
|
||||
"before_content": "https://tenor.com/view/14th-doctor-doctor-who-david-tennant-wild-blue-yonder-rage-gif-12474509552290072827",
|
||||
"after_content": "https://tenor.com/view/14th-doctor-doctor-who-david-tennant-wild-blue-yonder-rage-gif-12474509552290072827"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-19T22:19:14.095772",
|
||||
"event_type": "edited",
|
||||
"message_id": 1407458911929503875,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 353942721997832195,
|
||||
"author_name": "mg_studios_",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396130252144508939,
|
||||
1369669999366770724
|
||||
],
|
||||
"content": "https://tenor.com/view/dr-who-doctor-who-david-tennant-sad-annoyed-gif-4879151",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {
|
||||
"before_content": "https://tenor.com/view/dr-who-doctor-who-david-tennant-sad-annoyed-gif-4879151",
|
||||
"after_content": "https://tenor.com/view/dr-who-doctor-who-david-tennant-sad-annoyed-gif-4879151"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-19T22:24:31.079905",
|
||||
"event_type": "edited",
|
||||
"message_id": 1407460213409452235,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396112611292090368,
|
||||
1369669999366770723,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "OUi",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {
|
||||
"before_content": "OUi",
|
||||
"after_content": "Oui"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-19T22:50:46.776609",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1407466849981038592,
|
||||
"channel_id": 1369669999677145101,
|
||||
"channel_name": "┇🔧・salon-de-tests",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396112611292090368,
|
||||
1369669999366770723,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "https://www.perplexity.ai",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2025-08-20T00:42:00.965568",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1407494580709883994,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 1407448905817391215,
|
||||
"author_name": "test_33878",
|
||||
"author_roles": [
|
||||
1369669999345537145
|
||||
],
|
||||
"content": "",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-20T00:47:24.265760",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1407496141343559791,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 1407448905817391215,
|
||||
"author_name": "test_33878",
|
||||
"author_roles": [
|
||||
1369669999345537145
|
||||
],
|
||||
"content": "",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2025-08-23T20:54:00.125408",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1408887012731129877,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396112611292090368,
|
||||
1369669999366770723,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "G",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-23T20:54:00.775382",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1408887015570804787,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1396112611292090368,
|
||||
1369669999366770723,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "G",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,705 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:01.432517",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559052359631046,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:01.860802",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559053878235177,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:02.287326",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559055304167619,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:02.656882",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559057019633797,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:13.025829",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559100954837196,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:13.582926",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559103081353248,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:13.780030",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559104306217063,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:14.165698",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559105845395639,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:14.612894",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559107791556762,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:14.798228",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559108798185593,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:15.528887",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559111071629509,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:16.499969",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559109825790114,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g\ng",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:17.142340",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559112875049105,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:18.196550",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559117165826120,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:19.152467",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559114519482421,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "gg",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:21.275434",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559116280828005,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T14:52:21.407804",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455559118264729792,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:00:35.602064",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455561207493955786,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:00:35.638746",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455561208953442438,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "gg",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:00:36.363545",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455561211679604820,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "gg",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:00:36.826764",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455561214045454437,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:00:37.001960",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455561215060480071,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:00:37.594651",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455561215970644102,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g\ng",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:00:37.727681",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455561217610612777,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:00:40.753645",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455561219493728380,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:04:21.499784",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455562138142642443,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:04:23.080095",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455562136209199266,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:04:24.026167",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455562134749581559,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:04:25.951553",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455562133088637020,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:04:27.294471",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455562131473961092,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:04:28.245179",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455562129750102081,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:04:29.191484",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455562128076312689,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "gg",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:04:30.278917",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455562127220801537,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:36:52.387291",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455570313935847598,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:37:05.116440",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455570329702367242,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "gg",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:42:16.699177",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455571624299462862,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "https://www.ecoledirecte.com/login?cameFrom=%2FE%2F5835%2FEmploiDuTemps",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-12-30T15:42:24.379846",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1455571718037962854,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "https://www.ecoledirecte.com/login?cameFrom=%2FE%2F5835%2FEmploiDuTemps",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2026-01-01T21:05:28.276062",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456377795595730994,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-01T21:12:06.156108",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456379476211531970,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-01T21:12:06.462625",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456379478606348421,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "gg",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-01T21:12:12.104726",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456379484264595700,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-01T21:35:44.759055",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456385391564296297,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-01T21:35:51.064634",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456385392482844682,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-01T21:40:18.016792",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456386513947463855,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-01T21:40:21.147093",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456386515096703242,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-01T21:40:24.322926",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456386516439007288,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-01T21:43:53.352930",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456387431472435231,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-01T21:44:15.896248",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456387423477956670,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "g",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-01T21:44:32.743792",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456387525080776788,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "https://github.com/Omega-Kube/Kuby",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-01T21:44:43.533530",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456387651883237437,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "https://github.com/Omega-Kube/Kuby",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-01T21:51:23.069501",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456389356301582459,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "[DEBUG] anti_link_enabled: True, settings: {'anti_spam': True, 'anti_perm': True, 'anti_salon': True, 'anti_raid': True, 'anti_nuke': True, 'anti_link': True, 'anti_mention': True, 'verification_enabled': True, 'spam_threshold': 5, 'raid_threshold': 3, 'nuke_threshold': 3, 'log_channel_id': 1396088860412346428, 'lockdown_enabled': False, 'auto_ban': True, 'webhook_protection': True, 'mass_delete_protection': True, 'invite_filter': True, 'file_scanning': True, 'ai_moderation': True, 'channel_block_enabled': False, 'server_logs': True, 'anti_channel_delete': True, 'anti_role_create': True}",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-01T22:01:31.237385",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456391913694433361,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 1456390746167967932,
|
||||
"author_name": "juibu_66388",
|
||||
"author_roles": [
|
||||
1369669999345537145
|
||||
],
|
||||
"content": "https://github.com/Omega-Kube/Kuby",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2026-01-02T13:30:27.725564",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456625676537823243,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "gg",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-02T13:30:27.754495",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456625676537823243,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "gg",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-02T13:30:37.215876",
|
||||
"event_type": "deleted",
|
||||
"message_id": 1456625680153444448,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "gg",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2026-01-10T00:57:48.563188",
|
||||
"event_type": "edited",
|
||||
"message_id": 1459335326660300935,
|
||||
"channel_id": 1369669999677145098,
|
||||
"channel_name": "╭💬・général",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1369669999345537145,
|
||||
1369769836028231730,
|
||||
1369669999366770723,
|
||||
1403041729330024508,
|
||||
1369669999366770726
|
||||
],
|
||||
"content": "Non c'est gentil t'inqiutes",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {
|
||||
"before_content": "Non c'est gentil t'inqiutes",
|
||||
"after_content": "Non c'est gentil t’inquiètes"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2026-01-24T15:05:23.725825",
|
||||
"event_type": "edited",
|
||||
"message_id": 1464622068598575155,
|
||||
"channel_id": 1464621977376522291,
|
||||
"channel_name": "gameurpro12-écrit",
|
||||
"author_id": 971446412690722826,
|
||||
"author_name": "gameurpro12",
|
||||
"author_roles": [
|
||||
1366461904976871565,
|
||||
1453708383235473459,
|
||||
1454035592391299234,
|
||||
1454034686979346495,
|
||||
1453708344526246071,
|
||||
1454034988080038040,
|
||||
1366876636259024946
|
||||
],
|
||||
"content": "Les tickets prendront cette forme",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {
|
||||
"before_content": "Les tickets prendront cette forme",
|
||||
"after_content": "Les tickets whitelist prendront cette forme"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-24T15:05:52.316927",
|
||||
"event_type": "edited",
|
||||
"message_id": 1464622206439919658,
|
||||
"channel_id": 1460048825665454344,
|
||||
"channel_name": "🔢・nombres-infinis",
|
||||
"author_id": 1355564506754973767,
|
||||
"author_name": "alto1500",
|
||||
"author_roles": [
|
||||
1366461904976871565,
|
||||
1453708383235473459,
|
||||
1454042474141192316,
|
||||
1453708358417912075
|
||||
],
|
||||
"content": "24",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {
|
||||
"before_content": "24",
|
||||
"after_content": "240"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-01-24T15:23:49.868209",
|
||||
"event_type": "edited",
|
||||
"message_id": 1464626575415705761,
|
||||
"channel_id": 1453386193311105188,
|
||||
"channel_name": "📡・annonces",
|
||||
"author_id": 983747660265693258,
|
||||
"author_name": "mr_reiko_",
|
||||
"author_roles": [
|
||||
1366461904976871565,
|
||||
1453708383235473459,
|
||||
1454035592391299234,
|
||||
1454033803998920769,
|
||||
1454034686979346495,
|
||||
1464424459393040667,
|
||||
1454034988080038040,
|
||||
1453395626128707656,
|
||||
1369003041927200858,
|
||||
1366876636259024946
|
||||
],
|
||||
"content": "> # **__Annonce – Ouverture officielle des Whitelist__** <:emoji_1:1458776441041850430> \n> \n> **Nous vous informons que les Whitelist sont officiellement ouvertes.** <a:56125loading:1413178655038902482> \n> \n> - Pour effectuer votre WL,** il vous suffit de créer un ticket dans le salon** <#1450002116537090149> / <#1463081283679817822> prévu à cet effet.\n> Une fois le ticket ouvert, **un membre du staff prendra votre demande en charge,** vous expliquera l’ensemble de la procédure et **vous accompagnera tout au long du processus.** \n> \n> - __**Rôles disponibles en semi FA**__\n> **Les rôles suivants sont actuellement disponibles en semi FA pour les joueurs WL ecrite effectuée :**\n> \n> <@&1453708467700502561> \n> \n> <@&1453708401933553849> \n> \n> <@&1453708560511926273> \n> \n> <@&1453708517302337695> \n> \n> <@&1453708475132674070> \n> \n> <@&1453708562474860617> \n> \n> - **Merci de respecter les consignes et de faire preuve de sérieux lors de votre démarche.**\n> **Nous restons à votre disposition via les tickets pour toute question.** <:50773approvedids:1464433520301572096> £\n> \n> **Cordialement <@&1458084692803981354> / <@&1453708378336399392> /** <t:1769264525:S> \n> \n> ||@everyone ||",
|
||||
"attachments": [],
|
||||
"embeds": [],
|
||||
"details": {
|
||||
"before_content": "> # **__Annonce – Ouverture officielle des Whitelist__** <:emoji_1:1458776441041850430> \n> \n> **Nous vous informons que les Whitelist sont officiellement ouvertes.** <a:56125loading:1413178655038902482> \n> \n> - Pour effectuer votre WL,** il vous suffit de créer un ticket dans le salon** <#1450002116537090149> / <#1463081283679817822> prévu à cet effet.\n> Une fois le ticket ouvert, **un membre du staff prendra votre demande en charge,** vous expliquera l’ensemble de la procédure et **vous accompagnera tout au long du processus.** \n> \n> - __**Rôles disponibles en semi FA**__\n> **Les rôles suivants sont actuellement disponibles en semi FA pour les joueurs WL ecrite effectuée :**\n> \n> <@&1453708467700502561> \n> \n> <@&1453708401933553849> \n> \n> <@&1453708560511926273> \n> \n> <@&1453708517302337695> \n> \n> <@&1453708475132674070> \n> \n> <@&1453708562474860617> \n> \n> - **Merci de respecter les consignes et de faire preuve de sérieux lors de votre démarche.**\n> **Nous restons à votre disposition via les tickets pour toute question.** <:50773approvedids:1464433520301572096> £\n> \n> **Cordialement <@&1458084692803981354> / <@&1453708378336399392> /** <t:1769264525:S> \n> \n> ||@everyone ||",
|
||||
"after_content": "> # **__Annonce – Ouverture officielle des Whitelist__** <:emoji_1:1458776441041850430> \n> \n> **Nous vous informons que les Whitelist sont officiellement ouvertes.** <a:56125loading:1413178655038902482> \n> \n> - Pour effectuer votre WL,** il vous suffit de créer un ticket dans le salon** <#1450002116537090149> / <#1463081283679817822> prévu à cet effet.\n> Une fois le ticket ouvert, **un membre du staff prendra votre demande en charge,** vous expliquera l’ensemble de la procédure et **vous accompagnera tout au long du processus.** \n> \n> - __**Rôles disponibles en semi FA**__\n> **Les rôles suivants sont actuellement disponibles en semi FA pour les joueurs WL ecrite effectuée :**\n> \n> <@&1453708467700502561> \n> \n> <@&1453708401933553849> \n> \n> <@&1453708560511926273> \n> \n> <@&1453708517302337695> \n> \n> <@&1453708475132674070> \n> \n> <@&1453708562474860617> \n> \n> - **Merci de respecter les consignes et de faire preuve de sérieux lors de votre démarche.**\n> **Nous restons à votre disposition via les tickets pour toute question.** <:50773approvedids:1464433520301572096> \n> \n> **Cordialement <@&1458084692803981354> / <@&1453708378336399392> /** <t:1769264525:S> \n> \n> ||@everyone ||"
|
||||
}
|
||||
}
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue