Compare commits

..

150 commits

Author SHA1 Message Date
c1f9e56ec5 Merge pull request 'dev' (#240) from dev into main
Reviewed-on: #240
2026-07-11 22:15:25 +00:00
8736d52bf8 Merge branch 'dev' of https://omegakubeserv.tail951d2f.ts.net/Gameur/kuby into dev 2026-07-12 00:13:15 +02:00
187541f644 Corrections commentaires des suivi de fonctionnalité et bug 2026-07-12 00:10:09 +02:00
f6a940cb58 Merge pull request 'correction du modules bug_report' (#237) from dev into main
Reviewed-on: #237
2026-07-11 21:54:29 +00:00
fce14db3f9 Merge branch 'main' into dev 2026-07-11 21:54:11 +00:00
a87178b36d correction du modules bug_report 2026-07-11 23:50:19 +02:00
eb19e9b688 Merge pull request 'Correction migration vers Forgejo' (#236) from dev into main
Reviewed-on: #236
2026-07-11 21:46:16 +00:00
f31a5d42fb Merge main dans dev et résolution des conflits 2026-07-11 23:45:06 +02:00
38c8798c67 Correction migration vers Forgejo 2026-07-11 23:37:30 +02:00
2a2ed11156 résolution de conflit 2026-07-11 23:31:34 +02:00
e41c1702a3 Mise à jour : Migration du suivi des bugs et fonctionnalité via MP vers ForgeJo 2026-07-11 23:25:25 +02:00
d5351c6836 Merge branch 'dev' of https://omegakubeserv.tail951d2f.ts.net/Gameur/kuby 2026-07-11 23:13:20 +02:00
c2bcd5a2f2 Mise à jour : migration vers ForgeJo 2026-07-11 23:11:48 +02:00
d8ef20ba8c Mise à jour: migration de gitlab --> Forgejo 2026-07-11 21:42:45 +02:00
0e6a14dd10 Mise à jour : Passage des issues et du client vers Forgejo 2026-07-11 19:49:40 +02:00
9b4fbabbc5 Migration des intégration gitlab --> Forgejo 2026-07-11 18:54:27 +02:00
879355f792 Ajout de la monétisation V0.1 2026-07-06 16:08:14 +02:00
cd0fc4c488 Impossibilité de fermer un ticket pour les membre après prise en charge + correction affichage des invitations 2026-07-04 19:31:10 +02:00
eb07cb67a2 Fix: création de catégorie système de ticket kuby 2026-07-03 22:59:31 +02:00
d5167448df Merge branch 'dev' into 'main'
Dev

See merge request Omega_Kube/kuby!36
2026-06-25 17:49:09 +02:00
9051093fb3 Système WL modulaire + intégration des notations staffs 2026-06-25 17:48:12 +02:00
710ae134b0 Fix: système de ticket & optimisation 2026-06-18 23:12:51 +02:00
5a9e9b52d6 Ajout: Syncronisation automatique des pp discord des agent O.K sur le site via Kuby 2026-06-18 22:12:01 +02:00
b35cda27a6 Merge branch 'dev' into 'main'
Dev

See merge request Omega_Kube/kuby!35
2026-06-18 17:42:00 +02:00
886bfa4b1e Fix multiple bug et optimisation 2026-06-18 17:40:55 +02:00
b0535af75c Ajout de la possibilités de communiquer une information aux partenaires Omega Kube 2026-06-18 17:30:35 +02:00
8bcc40f217 V0.5 du système de rapport plusieurs choses à revoir avant MEP 2026-06-14 22:36:21 +02:00
7cc69b4e65 Merge branch 'dev' into 'main'
Fix:MEP auto

See merge request Omega_Kube/kuby!34
2026-06-07 16:41:21 +02:00
69cc25b99a Fix:MEP auto 2026-06-07 16:40:46 +02:00
944ec3432d Merge branch 'dev' into 'main'
Ajout système de tâches et Fix: suggerer et signaler_bug

See merge request Omega_Kube/kuby!33
2026-06-07 16:23:26 +02:00
f1c0921a58 Ajout système de tâches et Fix: suggerer et signaler_bug 2026-06-07 16:22:17 +02:00
c72451e0b7 Merge branch 'dev' into 'main'
Fix: MEP auto v3

See merge request Omega_Kube/kuby!32
2026-06-05 18:00:05 +02:00
a49aa6717f Fix: MEP auto v3 2026-06-05 17:59:37 +02:00
49c8310089 Merge branch 'dev' into 'main'
Fix: MEP auto v2

See merge request Omega_Kube/kuby!31
2026-06-05 17:54:18 +02:00
ad2b2050a3 Fix: MEP auto v2 2026-06-05 17:53:51 +02:00
47bd38f832 Merge branch 'dev' into 'main'
Fix: MEP auto v1

See merge request Omega_Kube/kuby!30
2026-06-05 17:50:56 +02:00
7ec4d75a8a Fix: MEP auto v1 2026-06-05 17:50:33 +02:00
4b2dd28583 Merge branch 'dev' into 'main'
Dev

See merge request Omega_Kube/kuby!29
2026-06-05 17:42:37 +02:00
30ebe7c773 Ajout d'un bouton permettant de désactiver la fonctionnalité de restauration des rôles 2026-06-05 17:42:07 +02:00
906338fbea correction de la commande de restart de pm2 2026-06-05 17:30:09 +02:00
28a67c8d8c Merge branch 'dev' into 'main'
Fix: récupération des issues_gitlab après incident

See merge request Omega_Kube/kuby!28
2026-06-04 08:40:54 +02:00
b6d2995916 Fix: récupération des issues_gitlab après incident 2026-06-04 08:40:14 +02:00
a70c1501e9 Merge branch 'dev' into 'main'
Fix:auto-déploiement & commentaire gitlab

See merge request Omega_Kube/kuby!27
2026-06-03 16:11:17 +02:00
ed6e25c219 Fix:auto-déploiement & commentaire gitlab 2026-06-03 16:10:36 +02:00
85b753b587 Merge branch 'dev' into 'main'
Fix de la base de données

See merge request Omega_Kube/kuby!26
2026-06-03 15:53:07 +02:00
ca5c966c9c Fix de la base de données 2026-06-03 15:52:43 +02:00
a1f06566d4 Merge branch 'dev' into 'main'
Dev

See merge request Omega_Kube/kuby!25
2026-05-31 12:25:57 +02:00
9f92d0d212 Merge branch 'main' into 'dev'
# Conflicts:
#   commandes/ticket/__init__.py
2026-05-31 12:25:01 +02:00
a169449390 Fix: système de ticket & arriver-départ 2026-05-31 12:07:24 +02:00
d9afa32828 Fix système arriver départ V1 2026-05-25 19:08:45 +02:00
Lowei
101f078f51 fix(ticket): corriger l'échec d'interaction caused par un custom_id dynamique
Le custom_id des boutons de catégorie incluait un snowflake basé sur
l'heure courante, ce qui changeait à chaque affichage du message et
empêchait Discord de matcher le bouton enregistré.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 17:38:00 +02:00
Lowei
6e314f081d fix(bug_report): unifier les handlers d'exception pour ne plus afficher "erreur inattendue" lors de MPs bloqués
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 17:17:37 +02:00
Lowei
9ddab71d83 fix(bug_report): separate exception handlers for DM blocked errors
Separate disnake.Forbidden and disnake.HTTPException handlers to properly
catch error code 50007 (DMs disabled) instead of falling through to the
outer "unexpected error" handler.
2026-05-23 15:30:01 +02:00
Lowei
ad8c43dddc Merge branch 'main' into Dev-Eliott 2026-05-23 15:23:36 +02:00
Lowei
2f5f4ce816 corriger: correction d'une coquille dans le formulaire de signalement de bug
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 15:21:07 +02:00
Lowei
5913305be9 fix(bug_report): envoyer le modal directement sans message intermédiaire
Les commandes /signaler_bug et /suggerer_fonctionnalite ouvrent maintenant
le formulaire modal directement sans passer par un embed + bouton.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 15:17:35 +02:00
Lowei
a9f577fc06 refactor(bug_report): utiliser bouton + modal pour le formulaire
Les commandes /signaler_bug et /suggerer_fonctionnalite affichent maintenant un embed
avec un bouton qui ouvre le formulaire modal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 15:13:56 +02:00
Lowei
8da858d1d8 feat(bug_report): convertir les commandes en formulaires modaux
Les commandes /signaler_bug et /suggerer_fonctionnalite ouvrent maintenant un formulaire modal au lieu d'afficher des options slash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 15:11:47 +02:00
Lowei
fac7ed1e6b fix(rules): améliorer le système de règlement
- Rendu markdown via Container avec TextDisplay au lieu de embed.description
- Commandes bug/suggestion utilisables avec arguments directs (copy-paste supporté)
- Échappement des mentions @everyone/@here pour éviter notifications involontaires

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 13:15:53 +02:00
Lowei
e11f0d8b4a fix(ticket): isoler les permissions staff par catégorie
Le bug faisait que les rôles staff ajoutés via le panneau d'une
catégorie spécifique étaient incorrectement stockés dans config_roles
au lieu de staff_roles de la catégorie. Corrige PermissionConfigView
pour gérer correctement config_type="cat_{id}".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 12:48:08 +02:00
Lowei
19fa423210 Fix: mise à jour des catégories dans le panel /ticket 2026-05-23 10:58:21 +02:00
Lowei
3534363f98 fix(bot): améliorer la robustesse des handlers d'erreurs Discord
- advanced_logger.py: parsing NDJSON plus robuste pour les fichiers JSON
  corrompus ou malformés lors de la vérification des rôles précédents.
  Évite l'erreur "Expecting value: line 2 column 1".

- bot.py: simplification de la détection d'erreur 403 Missing Permissions
  (codes 50013 et 20023) lors de la restauration des rôles au join d'un membre.
  Évite le log d'erreur "Failed to restore safe roles" quand c'est juste un
  problème de permissions/bot role hierarchy.

- bug_report.py: simplification de la détection du code 50007 (Cannot send
  messages to this user) lors de l'envoi de notifications de labels GitLab.
  Évite le log "Erreur inattendue dans la notification de label".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 18:46:32 +02:00
Lowei
6621e87b56 fix(ticket): éviter le double envoi de réponse sur les boutons primary panel
Les boutons ticket_create, ticket_my_tickets et ticket_analytics avaient
leur callback assigné directement dans TicketPanelView ET étaient aussi
routés via on_interaction. Cela provoquait une double réponse
d'interaction → erreur 40060 "Interaction has already been acknowledged".

Suppression des assignations de callback directes pour ces trois boutons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 18:29:56 +02:00
Lowei
f570ac4fce fix(logger): handle empty/corrupt member log files gracefully
Return None instead of crashing when check_previous_roles finds no
parsable JSON lines in a member's log file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 18:25:55 +02:00
Lowei
b3db1ef9f5 feat(ticket): système de ticket recrutement avec questions et DM accept/refus
- Ajout catégorie recrutement avec questions personnalisées
- Modal dynamique affichant les questions lors de la création
- Envoi des réponses dans un salon staff configurable
- Fermeture avec boutons Accepter/Refuser et envoi DM automatique au candidat
- Mise à jour des modèles TicketCategory et TicketData

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 14:26:02 +02:00
Lowei
db4876e085 feat(moderation): DM détaillé lors des sanctions + commande /sanctions + salon de contestation
- Ajout d'un DM formaté (action, modérateur, durée, raison, lien contestation, lien règlement)
- Envoi automatique du DM pour warn, timeout, kick, ban
- Nouvelle commande /sanctions pour voir l'historique d'un membre
- Nouveau champ appeal_channel_id pour le salon de contestation
- Mise à jour de /modconfig avec bouton de configuration du salon de contestation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 23:02:00 +02:00
Lowei
9546e13001 fix(bug-report): éviter la boucle infinie quand le dev a ses DMs bloqués
Si user.send() et dev.send() échouent tous deux avec Forbidden,
l'exception était rattrapée par le handler externe qui réessayait
d'envoyer à dev. On ignore désormais silencieusement l'échec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 22:48:19 +02:00
Lowei
59c91e4413 fix(bug-report): éviter la boucle infinie quand le dev a ses DMs bloqués
Si user.send() et dev.send() échouent tous deux avec Forbidden,
l'exception était rattrapée par le handler externe qui réessayait
d'envoyer à dev, causant une boucle jusqu'à StackOverflow.
On ignore désormais silencieusement l'échec si même le dev ne
peut recevoir de DM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-21 22:46:14 +02:00
ce1c8fac78 Merge branch 'dev' into 'main'
Debug GitLab note DM v8

See merge request Omega_Kube/kuby!24
2026-05-20 16:44:27 +02:00
01dd4979d8 Debug GitLab note DM v8 2026-05-20 16:43:54 +02:00
8f2e33559e Merge branch 'dev' into 'main'
Debug GitLab note DM v7

See merge request Omega_Kube/kuby!23
2026-05-20 15:50:12 +02:00
fbd225ea6d Debug GitLab note DM v7 2026-05-20 15:49:40 +02:00
32769077bc Merge branch 'dev' into 'main'
Debug GitLab note DM v7

See merge request Omega_Kube/kuby!22
2026-05-20 15:34:55 +02:00
2a3d57159a Debug GitLab note DM v7 2026-05-20 15:34:34 +02:00
34e73a47d1 Merge branch 'dev' into 'main'
Debug GitLab note DM v6

See merge request Omega_Kube/kuby!21
2026-05-20 15:23:28 +02:00
4dad380a50 Debug GitLab note DM v6 2026-05-20 15:23:09 +02:00
33443b9da6 Merge branch 'dev' into 'main'
Debug GitLab note DM v5

See merge request Omega_Kube/kuby!20
2026-05-20 14:49:42 +02:00
edf3aada73 Debug GitLab note DM v5 2026-05-20 14:48:39 +02:00
33bbf7ff8c Merge branch 'dev' into 'main'
Debug GitLab note DM v4

See merge request Omega_Kube/kuby!19
2026-05-20 14:31:21 +02:00
8dab5bbd82 Debug GitLab note DM v4 2026-05-20 14:30:31 +02:00
Lowei
db3d15b127 Debug GitLab note DM v3 2026-05-19 23:06:43 +02:00
Lowei
088ff1e849 Debug GitLab note DM v2 2026-05-19 23:01:34 +02:00
Lowei
761e7c0e16 Debug GitLab note DM 2026-05-19 21:19:43 +02:00
Lowei
8efd0dc61c Fix: retirer saut de ligne parasite dans la notif commentaire GitLab 2026-05-19 21:03:03 +02:00
Lowei
71f6218ea1 Merge: notification MP auteur sur commentaires GitLab 2026-05-19 20:58:59 +02:00
Lowei
f892a1ceba Fix: notifier l'auteur Discord quand un commentaire GitLab est ajouté 2026-05-19 20:52:29 +02:00
Lowei
91a17acf04 fix(welcome): masquage du lien brut du GIF de bienvenue via un Embed 2026-05-19 20:16:39 +02:00
Lowei
10558c47ec fix(bug-report): correction de la récupération des valeurs dans les modals de bug et de suggestion 2026-05-19 20:05:23 +02:00
Lowei
9731e10ee0 fix(ticket): correction de l'erreur d'indentation pour l'envoi du log de fermeture 2026-05-19 19:52:24 +02:00
cacbf24bfe Merge branch 'dev' into 'main'
Derniers ajouts en date

See merge request Omega_Kube/kuby!18
2026-05-19 18:50:39 +02:00
0a14f11eae Derniers ajouts en date 2026-05-19 18:50:16 +02:00
1c18e91671 Merge branch 'dev' into 'main'
Mise à jour du système d'absence staff

See merge request Omega_Kube/kuby!17
2026-05-16 20:03:41 +02:00
d8b2f06f86 Mise à jour du système d'absence staff 2026-05-16 20:02:53 +02:00
6b4414c9ce Merge branch 'dev' into 'main'
mise à jour du seveur-flask

See merge request Omega_Kube/kuby!16
2026-05-16 18:59:08 +02:00
ae2347c7ce mise à jour du seveur-flask 2026-05-16 18:58:29 +02:00
036e4b9e7a Merge branch 'dev' into 'main'
nouvel correction du système d'absence

See merge request Omega_Kube/kuby!15
2026-05-16 18:43:32 +02:00
c1cc28d6e8 nouvel correction du système d'absence 2026-05-16 18:36:03 +02:00
2d780c9d57 Merge branch 'dev' into 'main'
Corection urgente du système d'absence

See merge request Omega_Kube/kuby!14
2026-05-16 18:32:09 +02:00
a1bbb05ee2 Corection urgente du système d'absence 2026-05-16 18:31:49 +02:00
69e5583e74 Merge branch 'dev' into 'main'
New components V2, correctifs multiple et ajout de plusieurs nouvelles fonctionnalités !

See merge request Omega_Kube/kuby!13
2026-05-16 18:06:58 +02:00
ba9e297cb8 migration vers new components V2, et ajout de diverse fonctions ! 2026-05-16 18:05:04 +02:00
3590cb748f Implémentation des new components V2 dans le systèmes de ticket & debug 2026-05-09 22:11:17 +02:00
98f7501e07 Migration vers Disnake et ajout des components V2 2026-05-09 18:42:42 +02:00
c8c579eefe Migration de Kuby vers disnake 2026-05-06 17:07:09 +02:00
b97fe72f19 Merge branch 'dev' into 'main'
Ajout du système de sanctions, mise en déploiement pour test à grande échelle

See merge request Omega_Kube/kuby!12
2026-05-01 16:46:48 +02:00
dc6e235f27 Ajout du système de sanctions, mise en déploiement pour test à grande échelle 2026-05-01 16:44:50 +02:00
f2e58f6ba0 Merge branch 'dev' into 'main'
Correction des bugs de Red Dust Redemption et ajout des fonctionnalités demander

See merge request Omega_Kube/kuby!11
2026-05-01 16:19:45 +02:00
00446c5fb8 Fix des derniers détails 2026-05-01 16:17:55 +02:00
07198c20b7 Correction des multiples bugs 2026-05-01 16:16:33 +02:00
0f8d27ac7a Merge branch 'dev' into 'main'
Fix(Ticket Whitelist): Ajout système async et tracking pour le deblocage des validation rate limitees (10 min) + infos MP

See merge request Omega_Kube/kuby!10
2026-04-02 19:17:33 +02:00
5fe70d754b Fix(Ticket Whitelist): Ajout systeme async et tracking pour le deblocage des validation rate limitees (10 min) + infos MP 2026-04-02 19:06:50 +02:00
97f69b4cff feat: add debug messages for ticket validation and update PENDING_TASKS.md 2026-03-31 22:09:00 +02:00
1e384032be Mise à jour du système de whitelist, sécurité et logging 2026-03-29 19:29:17 +02:00
d82f704c75 fix: restauration du design des MPs de validation et ajout du choix de statut à la réouverture des tickets 2026-03-28 22:23:23 +01:00
9045b374fa fix: stabilisation des boutons de gestion de ticket (Fermer, Réouvrir, Claim) avec defer() 2026-03-28 22:17:17 +01:00
c8d5bf2c4b fix: correction du renommage des salons et ajout de feedback visuel dans les tickets 2026-03-28 22:12:36 +01:00
4531b99be4 fix: regroupement des messages DM de bienvenue pour éviter les limites de débit (erreur 40003) 2026-03-28 22:07:56 +01:00
148ddbe9bc fix: sécurisation de l'écriture des logs (atomique) et gestion de la corruption JSON dans AdvancedLogger 2026-03-28 22:05:55 +01:00
16263d88bc fix: resolve merge conflicts in ticket_whitelist, add retry logic for port 5001 in bug_report, and implement live GitLab issue deduplication in logger 2026-03-28 21:58:44 +01:00
c40c3f1f12 Fix des bugs de rôle & Amélioration du système de validation des formulaire whitelist 2026-03-15 16:53:38 +01:00
e61c568f77 Corrections bug mineur envoie de DM 2026-03-15 15:48:28 +01:00
432611d4ee correction de bugs de l'auto-déploiement 2026-03-15 12:04:18 +01:00
1aea27e576 Merge branch 'dev' into 'main'
V1.1 du module de déploiement automatique sur le serveur (URGENT)

See merge request Omega_Kube/kuby!9
2026-03-14 23:18:39 +01:00
3d52144ad7 V1.1 du module de déploiement automatique sur le serveur 2026-03-14 23:18:08 +01:00
7fa2b1819a Merge branch 'dev' into 'main'
Correction des derniers bugs recensés

See merge request Omega_Kube/kuby!8
2026-03-14 22:52:24 +01:00
702ad29822 Retrait de l'option mot de passe de la commande signaler et suggerer bug/fonctionnalité 2026-03-14 22:49:38 +01:00
a611f1beec Ajout dépendance dans le requirements.txt 2026-03-14 22:39:01 +01:00
a41a6f35fd Merge branch 'dev' into 'main'
V1.3 du système de gestions des rapports d'utilisateur

See merge request Omega_Kube/kuby!7
2026-03-14 18:35:37 +01:00
813ddeb802 V1.3 du système de gestions des rapports d'utilisateur 2026-03-14 18:35:18 +01:00
53869aa670 Merge branch 'dev' into 'main'
V1.2 du système de gestions des rapports d'utilisateur #2

See merge request Omega_Kube/kuby!6
2026-03-14 17:57:59 +01:00
22b3c2a3c4 V1.2 du système de gestions des rapports d'utilisateur #2 2026-03-14 17:56:26 +01:00
f734fba0db Merge branch 'dev' into 'main'
V1.1 du système de gestions des rapports d'utilisateur

See merge request Omega_Kube/kuby!5
2026-03-14 17:30:58 +01:00
d794502508 V1.1 du système de gestions des rapports d'utilisateur 2026-03-14 17:30:11 +01:00
57d7082ec0 Merge branch 'dev' into 'main'
MEP du suivie des tâches modularisation du système WL & boutons persistant sur l'ensemble du bot

See merge request Omega_Kube/kuby!4
2026-03-14 17:09:47 +01:00
d155ebfcb0 Première version de la mise à jour du suivie des bugs 2026-03-14 17:05:20 +01:00
fd9251fc6e Modularisation du système de ticket & ajout des boutons persistant système WL 2026-03-14 15:40:19 +01:00
be8bf3fa5c Mise à jour du gitignore 2026-03-13 22:42:08 +01:00
8d15d05c3d Fix: ignore CommandNotFound errors in on_command_error (e.g. git reset --hard origin/main) 2026-03-10 22:12:55 +01:00
9974a2b6b3 Merge branch 'dev' into 'main'
Intégration récupération automatique des infos des agent O.K

See merge request Omega_Kube/kuby!3
2026-03-01 01:35:20 +01:00
058473260a V1.3 prod, pour auto récup des infos des agent O.K sur le website 2026-03-01 01:28:33 +01:00
b5fd8b6526 V1.2 récupération automatique des informations des agent O.K sur le website 2026-03-01 01:08:35 +01:00
fe05572549 V1.0 de l'intégration de la feature de récupération automatique des informations des agents O.K sur le siteweb 2026-03-01 01:01:15 +01:00
d116bb4141 Ajout du système d'absence staff (modulaire) 2026-02-27 17:22:45 +01:00
cc94e85105 Ajout du système de convocation 2026-02-27 14:18:01 +01:00
6372014c99 Correction du bug "wl is not found" 2026-02-27 12:26:52 +01:00
d41339f196 Merge branch 'dev' into 'main'
Nettoyage du repository

See merge request Omega_Kube/kuby!2
2026-02-27 00:57:27 +01:00
dd24225120 chore: stop tracking logs and configuration files 2026-02-26 23:20:42 +01:00
2e13142b38 AJout des fichiers de logs 2026-02-20 13:47:55 +01:00
b5cb907283 Maintenance de Kuby 2026-02-20 13:44:07 +01:00
997b897b28 Ajout d'un système de suggestion via intégration gitlab + correction de /setgoodbye et DM arrivée 2026-02-08 14:42:40 +01:00
144 changed files with 22330 additions and 54947 deletions

View file

@ -1,9 +0,0 @@
# Discord Configuration
DISCORD_TOKEN=votre_token_discord
APPLICATION_ID=votre_id_application
# API Configuration
API_HOST=127.0.0.1
API_PORT=5000
API_DEBUG=False
API_SECRET_KEY=votre-cle-secrete-api

47
.gitignore vendored
View file

@ -1,29 +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
# Documentation
# BOT_DOCUMENTATION.md
.DS_Store
.roo/
.continue/mcpServers/new-mcp-server.yaml
.continue/mcpServers/new-mcp-server-1.yaml
.gitignore

View file

@ -1,412 +0,0 @@
# Documentation du Bot Kuby
## Table des matières
1. [Présentation générale](#présentation-générale)
2. [Architecture du bot](#architecture-du-bot)
3. [Installation et configuration](#installation-et-configuration)
4. [Modules et fonctionnalités](#modules-et-fonctionnalités)
5. [Système de sécurité](#système-de-sécurité)
6. [Gestion des tickets](#gestion-des-tickets)
7. [Système de logs](#système-de-logs)
8. [API REST](#api-rest)
9. [Commandes disponibles](#commandes-disponibles)
10. [Base de données](#base-de-données)
11. [Dépannage](#dépannage)
---
## Présentation générale
Kuby est un bot Discord multifonctionnel développé en Python utilisant la bibliothèque `discord.py`. Il est conçu pour offrir une gestion complète de serveur Discord avec des fonctionnalités de modération, sécurité, gestion de tickets, et bien plus encore.
### Caractéristiques principales
- **Système de whitelist** pour contrôler l'accès aux commandes
- **Gestion de tickets** avancée avec permissions configurables
- **Système de sécurité** anti-spam et anti-raid
- **Logs détaillés** des activités du serveur
- **Messages de bienvenue/départ** personnalisables
- **Modération** avec blacklist et scan de sécurité
- **Système de rôles automatique** pour les membres rejoignant
- **API REST** pour intégration externe et monitoring
---
## Architecture du bot
### Fichiers principaux
#### `kuby.py` - Point d'entrée
- Charge les variables d'environnement
- Initialise et démarre le bot
- Gère les erreurs critiques de démarrage
#### `bot.py` - Classe principale du bot
- Définit la classe `MyBot` héritant de `commands.Bot`
- Configure les intents Discord
- Charge toutes les extensions/modules
- Implémente les événements Discord principaux
### Structure des dossiers
```
kuby/
├── bot.py # Classe principale du bot
├── kuby.py # Point d'entrée
├── commandes/ # Modules de commandes
│ ├── ticket/ # Système de tickets
│ ├── modules_security/ # Modules de sécurité
│ ├── whitelist.py # Gestion whitelist
│ ├── blacklist.py # Gestion blacklist
│ └── ...
├── src/ # Utilitaires et core
│ ├── api/ # API REST module
│ │ ├── app.py # Application Flask
│ │ ├── routes.py # Routes API
│ │ └── config.py # Configuration API
│ ├── logger.py # Système de logging
│ └── advanced_logger.py # Logs avancés
├── data/ # Données de configuration
├── utils/ # Fonctions utilitaires
└── requirements.txt # Dépendances Python
```
---
## Installation et configuration
### Prérequis
- Python 3.8+
- Un token de bot Discord
### Installation
1. Cloner le repository
2. Installer les dépendances :
```bash
pip install -r requirements.txt
```
### Configuration
Créer un fichier `.env` avec les variables suivantes :
```env
# Discord Configuration
DISCORD_TOKEN=votre_token_discord
APPLICATION_ID=votre_id_application
# API Configuration
API_HOST=127.0.0.1
API_PORT=5000
API_DEBUG=False
API_SECRET_KEY=votre-cle-secrete-api
# Partner Server IDs (for API)
PARTNER_IDS=111111111111111111,222222222222222222,333333333333333333
```
### Dépendances principales
- `discord.py>=2.3.0` - API Discord
- `python-dotenv>=1.0.0` - Gestion variables d'environnement
- `Pillow>=10.0.0` - Traitement d'images
- `aiohttp>=3.8.0` - Requêtes HTTP asynchrones
- `flask>=2.3.0` - Framework API REST
- `flask-cors>=4.0.0` - Support CORS pour l'API
---
## Modules et fonctionnalités
### 1. Système de Whitelist (`whitelist.py`, `whitelist_monitor.py`)
- **Fonction** : Contrôle l'accès aux commandes du bot
- **Mécanisme** : Vérification globale avant l'exécution de toute commande
- **Exceptions** :
- Propriétaire du bot (bypass automatique)
- Utilisateurs avec permissions tickets pour commandes spécifiques
### 2. Messages de bienvenue/départ (`welcome.py`, `goodbye.py`)
- **Fonctionnalités** :
- Messages personnalisables avec images
- Templates de bannières
- Support de polices personnalisées
- Restauration automatique des rôles
### 3. Système de sécurité (`security.py`)
- **Scan de sécurité** : Analyse des comptes suspects
- **Gestion des menaces** : Actions automatiques configurables
- **Logs de sécurité** : Traçabilité des incidents
### 4. Modules de sécurité avancés (`modules_security/`)
- **Anti-spam** : Détection et blocage des spams
- **Anti-raid** : Protection contre les raids massifs
- **Logs manager** : Gestion centralisée des logs
- **Rules** : Gestion des règles du serveur
### 5. Gestion des tickets (`ticket/`)
- **Création de tickets** : Interface complète pour les utilisateurs
- **Permissions** : Système granulaire d'accès
- **Configuration** : Paramètres par serveur
- **Support** : Outils de modération intégrés
---
## Système de sécurité
### Niveaux de protection
1. **Whitelist globale** : Contrôle d'accès aux commandes
2. **Anti-spam** : Détection de messages répétitifs
3. **Anti-raid** : Protection contre les arrivées massives
4. **Scan de sécurité** : Analyse des comptes suspects
5. **Blacklist** : Bannissement automatique
### Configuration de sécurité
- Paramètres stockés par serveur
- Actions configurables (kick, ban, avertissement)
- Logs détaillés pour audit
---
## Gestion des tickets
### Fonctionnalités
- **Création** : Interface utilisateur simple
- **Catégories** : Organisation par type de demande
- **Permissions** : Accès contrôlé par rôles
- **Transcription** : Sauvegarde des conversations
- **Statistiques** : Suivi des tickets
### Configuration
```python
# Exemple de configuration ticket
config = {
"category_id": 123456789,
"support_role_id": 987654321,
"log_channel_id": 555666777,
"transcript_channel_id": 888999000
}
```
---
## API REST
Le bot Kuby inclut une API REST basée sur Flask qui permet d'accéder à certaines fonctionnalités du bot via des requêtes HTTP.
### Configuration de l'API
L'API se configure via les variables d'environnement :
- `API_HOST` : Adresse d'écoute (défaut: 127.0.0.1)
- `API_PORT` : Port d'écoute (défaut: 5000)
- `API_DEBUG` : Mode debug (défaut: False)
- `API_SECRET_KEY` : Clé secrète pour la sécurité
### Endpoints disponibles
#### `GET /api/user/<user_id>`
Retourne les informations d'un utilisateur Discord.
**Réponse :**
```json
{
"username": "Nom d'utilisateur",
"avatar_url": "https://cdn.discordapp.com/avatars/user_id/avatar.png"
}
```
#### `GET /api/partners`
Retourne la liste des serveurs partenaires configurés.
**Réponse :**
```json
[
{
"name": "Nom du serveur",
"icon_url": "https://cdn.discordapp.com/icons/guild_id/icon.png",
"member_count": 150
}
]
```
#### `GET /api/status`
Retourne le statut actuel du bot.
**Réponse :**
```json
{
"status": "online",
"username": "Kuby",
"guild_count": 5,
"total_members": 1250
}
```
#### `GET /api/guilds`
Retourne la liste des serveurs où le bot est présent.
**Réponse :**
```json
[
{
"id": 123456789,
"name": "Serveur Discord",
"icon_url": "https://cdn.discordapp.com/icons/guild_id/icon.png",
"member_count": 250,
"owner_id": 987654321
}
]
```
### Utilisation
L'API démarre automatiquement avec le bot et est accessible sur `http://127.0.0.1:5000` par défaut.
**Exemple d'utilisation :**
```bash
# Obtenir les infos d'un utilisateur
curl http://127.0.0.1:5000/api/user/123456789
# Obtenir le statut du bot
curl http://127.0.0.1:5000/api/status
```
### Sécurité
- L'API utilise CORS pour permettre les requêtes cross-origin
- Les logs d'accès sont enregistrés via le système de logging du bot
- L'API s'exécute dans un thread séparé pour ne pas bloquer le bot Discord
---
## Système de logs
### Types de logs
1. **Logs standards** (`kuby_logs.log`) : Activités générales
2. **Audit logs** (`kuby_audit.log`) : Actions de modération
3. **Error logs** (`kuby_errors.log`) : Erreurs et exceptions
4. **Performance logs** : Métriques de performance
### Fonctionnalités avancées
- **Advanced Logger** : Suivi détaillé des événements
- **Restauration de rôles** : Mémorisation des rôles des membres
- **Notifications** : Messages de départ/arrivée
- **Voice tracking** : Suivi des salons vocaux
---
## Commandes disponibles
### Commandes de modération
- `!whitelist` : Gestion de la whitelist
- `!blacklist` : Gestion de la blacklist
- `!scan` : Analyse de sécurité
- `!security` : Configuration sécurité
### Commandes de tickets
- `!ticket` : Gestion des tickets
- `!ticketconfig` : Configuration du système
- `!ticketsetup` : Initialisation rapide
### Commandes utilitaires
- `!snipe` : Récupération de messages supprimés
- `!bug_report` : Rapport de bugs
- `!sendbotmessages` : Messages automatisés
### Commandes de configuration
- `!welcome` : Messages de bienvenue
- `!goodbye` : Messages de départ
- `!nolink` : Filtrage des liens
---
## Base de données
### Fichiers de données
- `config.db` : Configuration principale (SQLite)
- `whitelist.json` : Liste des utilisateurs autorisés
- Données par serveur dans le dossier `data/`
### Structure de la base de données
```sql
-- Tables principales
- guilds : Configuration par serveur
- users : Données utilisateurs
- tickets : Historique des tickets
- audit_logs : Logs de modération
```
---
## Dépannage
### Problèmes courants
#### Token Discord invalide
```
❌ Erreur: Token Discord non trouvé
```
**Solution** : Vérifier le fichier `.env` et le token Discord
#### Extensions non chargées
```
❌ Erreur lors du chargement de l'extension
```
**Solution** : Vérifier les imports et la syntaxe dans les modules
#### Permissions manquantes
```
❌ Vous n'êtes pas autorisé à utiliser cette commande
```
**Solution** : Ajouter l'utilisateur à la whitelist ou vérifier les permissions
### Logs utiles pour le dépannage
- `kuby_errors.log` : Erreurs détaillées avec stack traces
- `debug.log` : Informations de débogage
- Console output : Logs en temps réel
### Commandes de maintenance
```python
# Redémarrage du bot
!restart
# Vérification du statut
!status
# Rechargement des modules
!reload <module_name>
```
---
## Bonnes pratiques
### Sécurité
- Garder le token Discord secret
- Utiliser la whitelist pour limiter l'accès
- Configurer correctement les permissions
- Surveiller régulièrement les logs
### Performance
- Limiter le nombre de commandes simultanées
- Optimiser les requêtes base de données
- Utiliser le cache pour les données fréquemment accédées
- Surveiller l'utilisation mémoire
### Maintenance
- Sauvegarder régulièrement la base de données
- Mettre à jour les dépendances
- Nettoyer les anciens logs
- Documenter les modifications
---
## Support et développement
### Pour obtenir de l'aide
1. Consulter les logs d'erreurs
2. Vérifier la configuration
3. Tester avec des commandes simples
4. Contacter le développeur si nécessaire
### Contributeurs
Le bot Kuby est développé avec une architecture modulaire pour faciliter l'ajout de nouvelles fonctionnalités. Chaque module est indépendant et peut être maintenu séparément.
---
*Document généré automatiquement - Dernière mise à jour : 2025*

BIN
EXEMPLE_ARRIVER.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

319
EXPLICATION_NON_DEV.md Normal file
View file

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

490
Explication.md Normal file
View file

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

15
TODO.md Normal file
View file

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

259
TÂCHE_VICY.py Executable file
View 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))

View 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())

View 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]}")

View 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.")

View 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())

View 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__')}")

View 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)}")

View 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

View 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}")

View 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}")

View 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}")

View 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}")

View file

@ -0,0 +1,3 @@
import disnake
print(disnake.ui.Container)
print(disnake.ui.TextDisplay)

View 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

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

260
bot.py
View file

@ -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,30 +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")
# Initialize API Manager
from src.api.app import APIManager
self.api_manager = APIManager(self)
kuby_logger.info("✅ API Manager initialized")
# Flask est géré par PM2 (kuby-flask), pas besoin de le lancer ici
# Start API server
try:
self.api_manager.run_async()
kuby_logger.info(f"🌐 API server started on http://{self.api_manager.config.HOST}:{self.api_manager.config.PORT}")
except Exception as e:
kuby_logger.error(f"❌ Erreur lors du démarrage de l'API : {e}", exc_info=True)
async def do_async_setup(self):
kuby_logger.info("🚀 Configuration initiale du bot Kuby...")
# Charger les extensions
extensions = [
"commandes.whitelist",
"commandes.whitelist_monitor",
@ -78,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):
@ -164,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)
@ -193,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")
@ -216,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
View 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
View 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
View 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")

View file

@ -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 nas 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 nas 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 tappartient 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 nas 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
View 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))

View file

@ -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 larchive 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))

View file

@ -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))

View file

@ -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 nas 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 nas 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 tappartient 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 nas 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 lensemble 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))

View file

@ -1,137 +1,439 @@
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
def classify_webhook_event(payload: Dict[str, Any]) -> Optional[str]:
"""Détermine si un webhook correspond à une issue ou à un commentaire."""
object_kind = payload.get("object_kind")
if object_kind in {"issue", "note"}:
return object_kind
bug_title = discord.ui.TextInput(
label="Titre du bug",
placeholder="Décrivez brièvement le problème...",
required=True,
max_length=100
)
action = str(payload.get("action") or payload.get("event") or "").lower()
if action in {"comment_created", "comment_updated", "commented", "issue_comment_created", "issue_comment_updated"}:
return "note"
if "comment" in payload or payload.get("comment") or "comment" in action:
return "note"
if payload.get("issue") or payload.get("object_attributes") or action in {"open", "reopen", "close", "closed", "updated", "created", "edited"}:
return "issue"
return None
def extract_issue_iid(payload: Dict[str, Any]) -> Optional[str]:
"""Extrait lidentifiant dissue depuis plusieurs formats Forgejo/GitLab."""
for container in (payload.get("object_attributes", {}), payload.get("issue", {}), payload.get("comment", {})):
if not isinstance(container, dict):
continue
for key in ("iid", "number", "id"):
value = container.get(key)
if value not in (None, ""):
return str(value)
return None
def extract_issue_identifier(issue_payload: Optional[Dict[str, Any]]) -> Optional[str]:
"""Récupère un identifiant stable à stocker dans le tracking."""
if not isinstance(issue_payload, dict):
return None
for key in ("iid", "number", "id"):
value = issue_payload.get(key)
if value not in (None, ""):
return str(value)
return None
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 lID de lissue créée depuis le retour Forgejo/GitLab")
await interaction.edit_original_response(content="✅ Votre rapport a été envoyé, mais lidentifiant de lissue na 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 lID de lissue créée depuis le retour Forgejo/GitLab")
await interaction.edit_original_response(content="✅ Votre suggestion a été envoyée, mais lidentifiant de lissue na 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 = classify_webhook_event(payload)
if event_type not in ["issue", "note"]:
kuby_logger.info(
"Webhook ignoré : type dévénement non reconnu (keys=%s action=%s object_kind=%s)",
list(payload.keys()),
payload.get("action") or payload.get("event"),
payload.get("object_kind"),
)
return web.json_response({"status": "ignored"}, status=200)
# Récupère l'IID de l'issue
issue_iid = extract_issue_iid(payload)
if not issue_iid:
kuby_logger.warning("Webhook ignoré : aucun identifiant dissue trouvé dans le payload")
return web.json_response({"status": "no issue id"}, status=200)
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)
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") or
payload.get("issue", {}).get("name") or
f"#{issue_iid}"
)
if event_type == "note":
# ⚡ NOUVEAU: Notifie les commentaires via le webhook (instantané)
comment_payload = payload.get("comment")
if isinstance(comment_payload, dict):
note_body = comment_payload.get("body") or comment_payload.get("content") or ""
author_name = (
payload.get("sender", {}).get("username")
or payload.get("sender", {}).get("login")
or payload.get("user", {}).get("name")
or "Développeur"
)
is_system = False
else:
note_attr = payload.get("object_attributes", {})
is_system = note_attr.get("system", False)
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 not is_system and 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 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
View 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))

View 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))

View 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"
}
}

View file

@ -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
View 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
View 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))

View file

@ -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))

View file

@ -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))

View file

@ -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))

View file

@ -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)

View file

@ -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
View 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))

View file

@ -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))

View file

@ -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))

View file

@ -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))

View file

@ -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))

View 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
View 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
View 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
View 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))

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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)
)

View file

@ -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 freeform 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,
)

View file

@ -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)

View file

@ -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,

View file

@ -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.
"""

View file

@ -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
View 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é.")

View file

@ -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))

View file

@ -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))

View file

@ -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

Binary file not shown.

View 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 astu découvert le serveur ?", "custom_id": "decouverte", "placeholder": "", "required": true, "style": "short", "max_length": 20},
{"label": "Astu un micro de qualité ?", "custom_id": "micro", "placeholder": "", "required": true, "style": "short", "max_length": 3}
]
}

View 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
View file

@ -0,0 +1,4 @@
{
"1369669999345537145": {},
"1402491843899555840": {}
}

View file

@ -0,0 +1,13 @@
{
"1369669999345537145_1337733859814543410": [
1370452086193655840
],
"1369669999345537145_1508391404831903836": {
"roles": [],
"overrides": {
"1510558364420149310": {
"view_channel": false
}
}
}
}

View file

@ -0,0 +1,11 @@
{
"1369669999345537145": {
"role_id": 1369669999366770723,
"log_channel_id": 1369669999677145103,
"admin_ids": [
353942721997832195,
971446412690722826
],
"attente_id": 1369669999677145099
}
}

View file

@ -1,5 +1,10 @@
{
"4": 971446412690722826,
"5": 971446412690722826,
"11": 971446412690722826
"15": 971446412690722826,
"153": 534003230435442690,
"160": 971446412690722826,
"161": 971446412690722826,
"162": 971446412690722826,
"164": 971446412690722826
}

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": {}
}
]

View file

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

View file

@ -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": {}
}
]

View file

@ -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": {}
}
]

View file

@ -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": {}
}
]

View file

@ -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": {}
}
]

View file

@ -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": {}
}
]

View file

@ -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": {}
}
]

View file

@ -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": {}
}
]

Some files were not shown because too many files have changed in this diff Show more