diff --git a/.gitignore b/.gitignore
index b9ff121..17f5e98 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@
__pycache__
instance
.tio.tokens.json
+**/logs
diff --git a/README.md b/README.md
index a26c22d..4823351 100755
--- a/README.md
+++ b/README.md
@@ -14,6 +14,8 @@
- [Prérequis](#prérequis)
- [Création du bot Discord](#création-du-bot-discord)
- [Démarrage rapide](#démarrage-rapide)
+ - [Build local (développement)](#build-local-développement)
+ - [Déploiement avec Portainer](#déploiement-avec-portainer)
- [Volumes persistants](#volumes-persistants)
- [Commandes Docker utiles](#commandes-docker-utiles)
- [Mise à jour](#mise-à-jour)
@@ -50,12 +52,49 @@ Mamie Henriette est un bot intelligent open-source développé spécifiquement p
- **Statuts dynamiques** : Rotation automatique des humeurs (10 min)
- **Notifications Humble Bundle** : Surveillance et alertes automatiques (30 min)
- **Commandes personnalisées** : Gestion via interface web
-- **Recherche ProtonDB** : Commande `!protondb ` pour vérifier la compatibilité Linux/Steam Deck
-- **Modération** : Outils intégrés
+- **Recherche ProtonDB** :
+ - Commande `!protondb nom_du_jeu` ou `!pdb nom_du_jeu` pour vérifier la compatibilité Linux/Steam Deck
+ - Recherche intelligente avec support des alias de jeux
+ - Affichage du score de compatibilité, nombre de rapports et lien direct
+ - **Intégration anti-cheat** : Affiche automatiquement les systèmes anti-cheat et leur statut (supporté, cassé, refusé)
+ - Cache mis à jour automatiquement depuis AreWeAntiCheatYet
+- **Modération** : Système complet de modération avec historique
+ - **Avertissements** : `!averto`, `!warn`, `!av`, `!avertissement`
+ - Envoi automatique de DM à l'utilisateur averti
+ - Support des timeouts combinés : `!warn @user raison --to durée`
+ - **Timeout** : `!timeout`, `!to` - Exclusion temporaire d'un utilisateur
+ - Syntaxe : `!to @user durée raison` (ex: `!to @User 10m Spam`)
+ - Durées supportées : secondes (s), minutes (m), heures (h), jours (j/days)
+ - **Gestion des avertissements** : `!delaverto`, `!removewarn`, `!delwarn`
+ - **Liste des événements** : `!warnings`, `!listevent`, `!listwarn`
+ - **Inspection utilisateur** : `!inspect @user`
+ - Historique complet des sanctions
+ - Date d'arrivée et durée sur le serveur
+ - Détection des comptes suspects (< 7 jours)
+ - Affichage du code d'invitation utilisé et de l'inviteur
+ - **Bannissement** : `!ban @user raison`, `!banlist`
+ - `!unban @user raison` ou `!unban #ID raison` (débannir par ID de sanction)
+ - Invitation automatique par DM lors du débannissement
+ - **Expulsion** : `!kick @user raison`
+ - **Annonces** : `!say #canal message` - Envoi de messages en tant que bot (staff uniquement)
+ - **Aide** : `!aide`, `!help` - Liste complète des commandes disponibles
+ - **Configuration avancée** :
+ - Support de multiples rôles staff
+ - Canal de logs dédié pour toutes les actions
+ - Suppression automatique des messages de modération (délai configurable)
+ - Activation/désactivation individuelle des fonctionnalités
+ - Panneau d'administration web pour consulter, éditer et supprimer l'historique
+- **Messages de bienvenue et départ** :
+ - Messages personnalisables avec variables : `{member.mention}`, `{member.name}`, `{server.name}`, `{server.member_count}`
+ - **Système de tracking d'invitations** : Affiche qui a invité le nouveau membre
+ - **Messages de départ intelligents** : Détection automatique de la raison (volontaire, kick, ban)
+ - Affichage de la durée passée sur le serveur
+ - Embeds enrichis avec avatar et informations détaillées
### Twitch
- **Chat bot** : Commandes et interactions automatiques
-- **Alertes Live** : Surveillance automatique des streamers (vérification toutes les 5 minutes)
+- **Alertes Live** :
+ - Surveillance automatique des streamers
- Support jusqu'à 100 chaînes simultanément
- Notifications Discord avec aperçu du stream
- Gestion via interface d'administration
@@ -67,10 +106,23 @@ Mamie Henriette est un bot intelligent open-source développé spécifiquement p
### Interface d'administration
- **Dashboard** : Vue d'ensemble et statistiques
-- **Configuration** : Tokens, paramètres des plateformes, configuration ProtonDB
-- **Gestion des humeurs** : Création et modification des statuts
-- **Commandes** : Édition des commandes personnalisées
-- **Modération** : Outils de gestion communautaire
+- **Configuration** :
+ - Tokens Discord/Twitch et paramètres des plateformes
+ - Configuration ProtonDB (API Algolia)
+ - Gestion des rôles staff (support de multiples rôles)
+ - Activation/désactivation individuelle des fonctionnalités (modération, ban, kick, welcome, leave)
+ - Configuration du délai de suppression automatique des messages de modération
+- **Gestion des humeurs** : Création et modification des statuts Discord rotatifs
+- **Commandes** : Édition des commandes personnalisées multi-plateformes
+- **Modération** :
+ - Consultation de l'historique complet des sanctions
+ - Édition des raisons des événements de modération
+ - Suppression d'événements de modération
+ - Filtrage et recherche dans l'historique
+- **Messages de bienvenue/départ** :
+ - Personnalisation des messages avec variables dynamiques
+ - Configuration des canaux de bienvenue et départ
+ - Activation/désactivation indépendante
## Installation
@@ -115,22 +167,123 @@ Avant d'installer MamieHenriette, vous devez créer un bot Discord et obtenir so
```bash
# 1. Cloner le projet
git clone https://github.com/skylanix/MamieHenriette.git
-```
-
-```bash
cd MamieHenriette
```
```bash
-# 2. Lancer avec Docker
-docker compose up --build -d
+# 2. Récupérer l'image depuis GitHub Container Registry et lancer
+docker compose pull
+docker compose up -d
```
-> ⚠️ **Important** : Après configuration via l'interface web http://localhost:5000, **redémarrez le conteneur** pour que les changements soient pris en compte :
+> 📝 L'interface web sera accessible sur http://localhost:5000
+>
+> ⚠️ **Important** : Après configuration via l'interface web, **redémarrez le conteneur** pour que les changements soient pris en compte :
> ```bash
> docker compose restart MamieHenriette
> ```
+### Build local (développement)
+
+Si vous souhaitez modifier le code et builder l'image localement :
+
+```bash
+# 1. Cloner et accéder au projet
+git clone https://github.com/skylanix/MamieHenriette.git
+cd MamieHenriette
+```
+
+```bash
+# 2. Modifier le docker-compose.yml
+# Commentez la ligne 'image:' et décommentez la section 'build:' :
+```
+
+```yaml
+services:
+ mamiehenriette:
+ container_name: MamieHenriette
+ restart: unless-stopped
+ build: . # ← Décommentez cette ligne
+ image: mamiehenriette # ← Décommentez cette ligne
+ # image: ghcr.io/skylanix/mamiehenriette:latest # ← Commentez cette ligne
+ # ... reste de la configuration
+```
+
+```bash
+# 3. Builder et lancer
+docker compose up --build -d
+```
+
+### Déploiement avec Portainer
+
+Si vous utilisez Portainer pour gérer vos conteneurs Docker, voici la configuration Docker Compose à utiliser :
+
+```yaml
+services:
+ mamiehenriette:
+ container_name: MamieHenriette
+ image: ghcr.io/skylanix/mamiehenriette:latest
+ restart: unless-stopped
+ environment:
+ TZ: Europe/Paris
+ volumes:
+ # Adaptez ces chemins selon votre configuration
+ - ./instance:/app/instance
+ - ./logs:/app/logs
+ ports:
+ - 5000:5000
+
+ watchtower: # Mise à jour automatique de l'image
+ image: containrrr/watchtower:latest
+ container_name: watchtower
+ restart: unless-stopped
+ environment:
+ TZ: Europe/Paris
+ WATCHTOWER_INCLUDE: "MamieHenriette"
+ WATCHTOWER_SCHEDULE: "0 */30 * * * *" # Vérification toutes les 30 min
+ WATCHTOWER_MONITOR_ONLY: "false"
+ WATCHTOWER_CLEANUP: "true"
+ WATCHTOWER_INCLUDE_RESTARTING: "true"
+ # Décommentez pour activer les notifications Discord :
+ # WATCHTOWER_NOTIFICATION_URL: "discord://token@id"
+ # WATCHTOWER_NOTIFICATIONS: shoutrrr
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+
+ # Décommentez pour accéder à la base de données via interface web (localhost:5001)
+ # sqlite-web:
+ # image: ghcr.io/coleifer/sqlite-web:latest
+ # container_name: sqlite_web
+ # ports:
+ # - "5001:8080"
+ # volumes:
+ # - ./instance/database.db:/data/database.db
+ # environment:
+ # - SQLITE_DATABASE=/data/database.db
+```
+
+**Étapes dans Portainer :**
+
+1. **Accéder à Portainer** : Ouvrez votre interface Portainer (généralement http://votre-serveur:9000)
+
+2. **Créer une Stack** :
+ - Allez dans "Stacks" → "Add stack"
+ - Donnez un nom : `MamieHenriette`
+ - Collez la configuration ci-dessus dans l'éditeur
+
+3. **Adapter les chemins des volumes** :
+ - Modifiez `./instance` et `./logs` selon votre configuration
+ - Exemple : `/opt/containers/MamieHenriette/instance` et `/opt/containers/MamieHenriette/logs`
+
+4. **Déployer** :
+ - Cliquez sur "Deploy the stack"
+ - Attendez que le conteneur démarre
+
+5. **Accéder à l'interface** :
+ - Ouvrez http://votre-serveur:5000
+ - Configurez le bot via l'interface web
+ - Redémarrez le conteneur depuis Portainer après configuration
+
### Volumes persistants
- `./instance/` : Base de données SQLite et configuration
- `./logs/` : Logs applicatifs rotatifs (50MB max par fichier)
@@ -165,10 +318,12 @@ git pull origin main
# 3. Mettre à jour l'image Docker
docker compose pull
-# 4. Reconstruire et relancer
-docker compose up --build -d
+# 4. Relancer
+docker compose up -d
```
+> 💡 **Note** : Si vous utilisez Watchtower, les mises à jour de l'image sont automatiques (vérification toutes les 30 minutes).
+
#### Sans Docker (installation locale)
```bash
# 1. Arrêter l'application
@@ -236,13 +391,16 @@ python run-web.py
## Spécifications techniques
### Base de données (SQLite)
-- **Configuration** : Paramètres et tokens des plateformes
+- **Configuration** : Paramètres et tokens des plateformes, configuration des fonctionnalités
- **Humeur** : Statuts Discord rotatifs avec gestion automatique
- **Commande** : Commandes personnalisées multi-plateformes (Discord/Twitch)
- **LiveAlert** : Configuration surveillance streamers Twitch (nom, canal Discord, statut)
- **GameAlias** : Alias pour améliorer les recherches ProtonDB
- **GameBundle** : Historique et notifications Humble Bundle
-- **Message** : Messages automatiques périodiques (implémenté)
+- **AntiCheatCache** : Cache des informations anti-cheat pour ProtonDB (mise à jour automatique hebdomadaire)
+- **Message** : Messages automatiques périodiques
+- **Moderation** : Historique complet des actions de modération (avertissements, timeouts, bans, kicks, unbans) avec raison, staff, timestamp et durée
+- **MemberInvites** : Tracking des invitations (code d'invitation, inviteur, date de join)
### Architecture multi-thread
- **Thread 1** : Interface web Flask (port 5000) avec logging rotatif
diff --git a/database/__init__.py b/database/__init__.py
index eb3a05b..9d1d48e 100644
--- a/database/__init__.py
+++ b/database/__init__.py
@@ -1,6 +1,8 @@
import logging
import json
import os
+from sqlalchemy import event
+from sqlalchemy.engine import Engine
from flask_sqlalchemy import SQLAlchemy
from sqlite3 import Cursor, Connection
@@ -9,8 +11,28 @@ from webapp import webapp
basedir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
webapp.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(basedir, "instance", "database.db")}'
+# Options moteur pour améliorer la concurrence SQLite
+webapp.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
+ 'connect_args': {
+ 'check_same_thread': False,
+ 'timeout': 30
+ },
+}
+webapp.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(webapp)
+# PRAGMA pour SQLite (WAL, busy timeout)
+@event.listens_for(Engine, "connect")
+def _set_sqlite_pragma(dbapi_connection, connection_record):
+ try:
+ cursor = dbapi_connection.cursor()
+ cursor.execute("PRAGMA journal_mode=WAL;")
+ cursor.execute("PRAGMA synchronous=NORMAL;")
+ cursor.execute("PRAGMA busy_timeout=30000;")
+ cursor.close()
+ except Exception:
+ pass
+
def _tableHaveColumn(table_name:str, column_name:str, cursor:Cursor) -> bool:
cursor.execute(f'PRAGMA table_info({table_name})')
columns = cursor.fetchall()
diff --git a/database/models.py b/database/models.py
index 308f11c..9f103d1 100644
--- a/database/models.py
+++ b/database/models.py
@@ -40,3 +40,24 @@ class Commande(db.Model):
trigger = db.Column(db.String(32), unique=True)
response = db.Column(db.String(2000))
+class ModerationEvent(db.Model):
+ id = db.Column(db.Integer, primary_key=True)
+ type = db.Column(db.String(32))
+ username = db.Column(db.String(256))
+ discord_id = db.Column(db.String(64))
+ created_at = db.Column(db.DateTime)
+ reason = db.Column(db.String(1024))
+ staff_id = db.Column(db.String(64))
+ staff_name = db.Column(db.String(256))
+ duration = db.Column(db.Integer)
+
+class AntiCheatCache(db.Model):
+ __tablename__ = 'anticheat_cache'
+ steam_id = db.Column(db.String(32), primary_key=True)
+ game_name = db.Column(db.String(256))
+ status = db.Column(db.String(32))
+ anticheats = db.Column(db.String(512))
+ reference = db.Column(db.String(512))
+ notes = db.Column(db.String(1024))
+ updated_at = db.Column(db.DateTime)
+
diff --git a/database/schema.sql b/database/schema.sql
index de38db0..04cef86 100644
--- a/database/schema.sql
+++ b/database/schema.sql
@@ -45,3 +45,34 @@ CREATE TABLE IF NOT EXISTS `commande` (
`trigger` VARCHAR(16) UNIQUE NOT NULL,
`response` VARCHAR(2000) NOT NULL
);
+
+CREATE TABLE IF NOT EXISTS `moderation_event` (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ `type` VARCHAR(32) NOT NULL,
+ `username` VARCHAR(256) NOT NULL,
+ `discord_id` VARCHAR(64) NOT NULL,
+ `created_at` DATETIME NOT NULL,
+ `reason` VARCHAR(1024) NOT NULL,
+ `staff_id` VARCHAR(64) NOT NULL,
+ `staff_name` VARCHAR(256) NOT NULL,
+ `duration` INTEGER NULL
+);
+
+CREATE TABLE IF NOT EXISTS `anticheat_cache` (
+ steam_id VARCHAR(32) PRIMARY KEY,
+ game_name VARCHAR(256) NOT NULL,
+ status VARCHAR(32) NOT NULL,
+ anticheats VARCHAR(512),
+ reference VARCHAR(512),
+ notes VARCHAR(1024),
+ updated_at DATETIME NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS `member_invites` (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ `user_id` VARCHAR(64) NOT NULL,
+ `guild_id` VARCHAR(64) NOT NULL,
+ `invite_code` VARCHAR(256),
+ `inviter_name` VARCHAR(256),
+ `join_date` DATETIME NOT NULL
+);
diff --git a/discordbot/__init__.py b/discordbot/__init__.py
index 837161c..1d0670e 100644
--- a/discordbot/__init__.py
+++ b/discordbot/__init__.py
@@ -6,8 +6,22 @@ import random
from database import db
from database.helpers import ConfigurationHelper
from database.models import Configuration, Humeur, Commande
-from discord import Message, TextChannel
+from discord import Message, TextChannel, Member
from discordbot.humblebundle import checkHumbleBundleAndNotify
+from discordbot.moderation import (
+ handle_warning_command,
+ handle_remove_warning_command,
+ handle_list_warnings_command,
+ handle_ban_command,
+ handle_kick_command,
+ handle_unban_command,
+ handle_inspect_command,
+ handle_ban_list_command,
+ handle_staff_help_command,
+ handle_timeout_command,
+ handle_say_command
+)
+from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
from protondb import searhProtonDb
class DiscordBot(discord.Client):
@@ -16,6 +30,9 @@ class DiscordBot(discord.Client):
for c in self.get_all_channels() :
logging.info(f'{c.id} {c.name}')
+ for guild in self.guilds:
+ await updateInviteCache(guild)
+
self.loop.create_task(self.updateStatus())
self.loop.create_task(self.updateHumbleBundle())
@@ -42,17 +59,34 @@ class DiscordBot(discord.Client):
if isinstance(channel, TextChannel):
channels.append(channel)
return channels
+
+ def getAllRoles(self):
+ guilds_roles = []
+ for guild in self.guilds:
+ roles = []
+ for role in guild.roles:
+ if role.name != "@everyone":
+ roles.append(role)
+ if roles:
+ guilds_roles.append({
+ 'guild_name': guild.name,
+ 'guild_id': guild.id,
+ 'roles': roles
+ })
+ return guilds_roles
def begin(self) :
token = Configuration.query.filter_by(key='discord_token').first()
- if token :
+ if token and token.value and token.value.strip():
self.run(token.value)
else :
logging.error('Aucun token Discord configuré. Le bot ne peut pas être démarré')
intents = discord.Intents.default()
intents.message_content = True
+intents.members = True
+intents.invites = True
bot = DiscordBot(intents=intents)
# https://discordpy.readthedocs.io/en/stable/quickstart.html
@@ -63,6 +97,54 @@ async def on_message(message: Message):
if not message.content.startswith('!'):
return
command_name = message.content.split()[0]
+
+ if ConfigurationHelper().getValue('moderation_enable'):
+ if command_name in ['!averto', '!av', '!avertissement', '!warn']:
+ await handle_warning_command(message, bot)
+ return
+
+ if command_name in ['!to', '!timeout']:
+ await handle_timeout_command(message, bot)
+ return
+
+ if command_name in ['!delaverto', '!removewarn', '!unwarn']:
+ await handle_remove_warning_command(message, bot)
+ return
+
+ if command_name in ['!listevent', '!listwarn', '!warnings']:
+ await handle_list_warnings_command(message, bot)
+ return
+
+ if ConfigurationHelper().getValue('moderation_ban_enable'):
+ if command_name == '!ban':
+ await handle_ban_command(message, bot)
+ return
+
+ if command_name == '!unban':
+ await handle_unban_command(message, bot)
+ return
+ if command_name == '!banlist':
+ await handle_ban_list_command(message, bot)
+ return
+
+ if ConfigurationHelper().getValue('moderation_kick_enable'):
+ if command_name == '!kick':
+ await handle_kick_command(message, bot)
+ return
+
+ if ConfigurationHelper().getValue('moderation_enable'):
+ if command_name == '!inspect':
+ await handle_inspect_command(message, bot)
+ return
+
+ if command_name == '!say':
+ await handle_say_command(message, bot)
+ return
+
+ if command_name in ['!aide', '!help']:
+ await handle_staff_help_command(message, bot)
+ return
+
commande = Commande.query.filter_by(discord_enable=True, trigger=command_name).first()
if commande:
try:
@@ -71,25 +153,94 @@ async def on_message(message: Message):
except Exception as e:
logging.error(f'Échec de l\'exécution de la commande Discord : {e}')
- if(ConfigurationHelper().getValue('proton_db_enable_enable') and message.content.find('!protondb')==0) :
+ # Commande !protondb ou !pdb avec embed
+ if (ConfigurationHelper().getValue('proton_db_enable_enable') and (message.content.startswith('!protondb') or message.content.startswith('!pdb'))):
if (message.content.find('<@')>0) :
mention = message.content[message.content.find('<@'):]
else :
mention = message.author.mention
- name = message.content.replace('!protondb', '').replace(f'{mention}', '').strip();
+ # Nettoyer le nom en enlevant la commande (!protondb ou !pdb)
+ name = message.content
+ if name.startswith('!protondb'):
+ name = name.replace('!protondb', '', 1)
+ elif name.startswith('!pdb'):
+ name = name.replace('!pdb', '', 1)
+ name = name.replace(f'{mention}', '').strip();
games = searhProtonDb(name)
if (len(games)==0) :
msg = f'{mention} Je n\'ai pas trouvé de jeux correspondant à **{name}**. Es-tu sûr que le jeu est disponible sur Steam ?'
- else :
- msg = f'{mention} J\'ai trouvé {len(games)} jeux :\n'
- ite = iter(games)
- while (game := next(ite, None)) is not None and len(msg) < 1850 :
- msg += f'- [{game.get('name')}](https://www.protondb.com/app/{game.get('id')}) classé **{game.get('tier')}**\n'
- rest = sum(1 for _ in ite)
- if (rest > 0):
- msg += f'- et encore {rest} autres jeux'
- try :
- await message.channel.send(msg, suppress_embeds=True)
- except Exception as e:
- logging.error(f'Échec de l\'envoi du message ProtonDB : {e}')
+ try:
+ await message.channel.send(msg, suppress_embeds=True)
+ except Exception as e:
+ logging.error(f"Échec de l'envoi du message ProtonDB : {e}")
+ return
+ # Construire un bel embed
+ embed = discord.Embed(
+ title=f"🔎 Résultats ProtonDB pour {name}",
+ color=discord.Color.blurple()
+ )
+ embed.set_footer(text=f"Demandé par {message.author.name}")
+
+ max_fields = 10
+ count = 0
+ for game in games:
+ if count >= max_fields:
+ break
+ g_name = str(game.get('name'))
+ g_id = str(game.get('id'))
+ tier = str(game.get('tier') or 'N/A')
+ # Anti-cheat info si disponible
+ ac_status = game.get('anticheat_status')
+ ac_emoji = ''
+ ac_text = ''
+ if ac_status:
+ status_lower = str(ac_status).lower()
+ if status_lower == 'supported':
+ ac_emoji, ac_text = '✅', 'Supporté'
+ elif status_lower == 'running':
+ ac_emoji, ac_text = '⚠️', 'Fonctionne'
+ elif status_lower == 'broken':
+ ac_emoji, ac_text = '❌', 'Cassé'
+ elif status_lower == 'denied':
+ ac_emoji, ac_text = '🚫', 'Refusé'
+ elif status_lower == 'planned':
+ ac_emoji, ac_text = '📅', 'Planifié'
+ else:
+ ac_emoji, ac_text = '❔', str(ac_status)
+ acs = game.get('anticheats') or []
+ ac_list = ', '.join([str(ac) for ac in acs if ac])
+ ac_line = f" | Anti-cheat: {ac_emoji} **{ac_text}**"
+ if ac_list:
+ ac_line += f" ({ac_list})"
+ else:
+ ac_line = ''
+ value = f"Tier: **{tier}**{ac_line}\nLien: https://www.protondb.com/app/{g_id}"
+ embed.add_field(name=g_name, value=value[:1024], inline=False)
+ count += 1
+
+ rest = max(0, len(games) - count)
+ if rest > 0:
+ embed.add_field(name="…", value=f"et encore {rest} autres jeux", inline=False)
+
+ try :
+ await message.channel.send(content=mention, embed=embed)
+ except Exception as e:
+ logging.error(f"Échec de l'envoi de l'embed ProtonDB : {e}")
+
+@bot.event
+async def on_member_join(member: Member):
+ await sendWelcomeMessage(bot, member)
+
+@bot.event
+async def on_member_remove(member: Member):
+ await sendLeaveMessage(bot, member)
+
+@bot.event
+async def on_invite_create(invite):
+ await updateInviteCache(invite.guild)
+
+@bot.event
+async def on_invite_delete(invite):
+ await updateInviteCache(invite.guild)
+
diff --git a/discordbot/moderation.py b/discordbot/moderation.py
new file mode 100644
index 0000000..8eb7a86
--- /dev/null
+++ b/discordbot/moderation.py
@@ -0,0 +1,1392 @@
+import asyncio
+import logging
+import time
+import os
+import re
+import discord
+from datetime import datetime, timezone, timedelta
+from zoneinfo import ZoneInfo
+from database import db
+from database.helpers import ConfigurationHelper
+from database.models import ModerationEvent
+from discord import Message
+
+def _get_local_tz():
+ tz_name = os.environ.get('APP_TZ') or os.environ.get('TZ') or 'Europe/Paris'
+ try:
+ return ZoneInfo(tz_name)
+ except Exception:
+ try:
+ return datetime.now().astimezone().tzinfo or timezone.utc
+ except Exception:
+ return timezone.utc
+
+def _to_local(dt: datetime) -> datetime | None:
+ if not dt:
+ return None
+ if dt.tzinfo is None:
+ # Assume stored in UTC if naive
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt.astimezone(_get_local_tz())
+
+def get_staff_role_ids():
+ staff_roles = ConfigurationHelper().getValue('moderation_staff_role_ids')
+ if staff_roles:
+ return [int(role_id.strip()) for role_id in staff_roles.split(',') if role_id.strip()]
+ staff_role_old = ConfigurationHelper().getValue('moderation_staff_role_id')
+ if staff_role_old:
+ return [int(staff_role_old)]
+ return []
+
+def has_staff_role(user_roles):
+ staff_role_ids = get_staff_role_ids()
+ if not staff_role_ids:
+ return False
+ return any(role.id in staff_role_ids for role in user_roles)
+
+def get_embed_delete_delay():
+ delay = ConfigurationHelper().getValue('moderation_embed_delete_delay')
+ return int(delay) if delay else 0
+
+async def delete_after_delay(message):
+ delay = get_embed_delete_delay()
+ if delay > 0:
+ await asyncio.sleep(delay)
+ try:
+ await message.delete()
+ except:
+ pass
+
+async def safe_delete_message(message: Message):
+ try:
+ await message.delete()
+ except:
+ pass
+
+async def send_to_moderation_log_channel(bot, embed):
+ try:
+ channel_id = ConfigurationHelper().getIntValue('moderation_log_channel_id')
+ if not channel_id:
+ logging.warning("Aucun canal de logs de modération configuré")
+ return
+
+ channel = bot.get_channel(channel_id)
+ if not channel:
+ logging.warning(f"Canal de logs de modération introuvable (ID: {channel_id})")
+ return
+
+ await channel.send(embed=embed)
+ except Exception as e:
+ logging.error(f"Erreur lors de l'envoi dans le canal de logs : {e}")
+
+async def send_access_denied(channel):
+ embed = discord.Embed(
+ title="❌ Accès refusé",
+ description="Vous n'avez pas les permissions nécessaires pour utiliser cette commande.",
+ color=discord.Color.red()
+ )
+ msg = await channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+async def send_user_not_found(channel):
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description="Utilisateur introuvable. Vérifiez la mention ou l'ID Discord.",
+ color=discord.Color.red()
+ )
+ msg = await channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+def parse_timeout_duration(text: str):
+ match = re.search(r'--to(?:meout)?[= ]?(\d+)([smhj])?', text.lower())
+ if not match:
+ return None
+
+ value = int(match.group(1))
+ unit = match.group(2) or 'm'
+
+ if unit == 's':
+ return value
+ elif unit == 'm':
+ return value * 60
+ elif unit == 'h':
+ return value * 3600
+ elif unit == 'j':
+ return value * 86400
+ return None
+
+def format_timeout_duration(seconds: int) -> str:
+ if seconds < 60:
+ return f"{seconds} seconde{'s' if seconds > 1 else ''}"
+ elif seconds < 3600:
+ minutes = seconds // 60
+ return f"{minutes} minute{'s' if minutes > 1 else ''}"
+ elif seconds < 86400:
+ hours = seconds // 3600
+ return f"{hours} heure{'s' if hours > 1 else ''}"
+ else:
+ days = seconds // 86400
+ return f"{days} jour{'s' if days > 1 else ''}"
+
+async def parse_target_user_and_reason(message, bot, parts: list):
+ full_text = message.content
+ timeout_seconds = parse_timeout_duration(full_text)
+
+ if message.mentions:
+ target_user = message.mentions[0]
+ reason_text = parts[2] if len(parts) > 2 else "Sans raison"
+ reason_text = re.sub(r'--to(?:meout)?[= ]?\d+[smhj]?', '', reason_text, flags=re.IGNORECASE).strip()
+ if not reason_text:
+ reason_text = "Sans raison"
+ return target_user, reason_text, timeout_seconds
+
+ try:
+ user_id = int(parts[1])
+ target_user = await bot.fetch_user(user_id)
+ reason_text = parts[2] if len(parts) > 2 else "Sans raison"
+ reason_text = re.sub(r'--to(?:meout)?[= ]?\d+[smhj]?', '', reason_text, flags=re.IGNORECASE).strip()
+ if not reason_text:
+ reason_text = "Sans raison"
+ return target_user, reason_text, timeout_seconds
+ except (ValueError, discord.NotFound):
+ return None, None, None
+
+async def send_warning_usage(channel):
+ embed = discord.Embed(
+ title="📋 Utilisation de la commande",
+ description="**Syntaxe :** `!averto @utilisateur raison` ou `!averto raison`\n**Option :** Ajouter `--to durée` pour exclure temporairement l'utilisateur",
+ color=discord.Color.blue()
+ )
+ embed.add_field(name="Exemples", value="• `!averto @User Spam dans le chat`\n• `!warn @User Comportement inapproprié --to 10m`\n• `!av @User --to 1h`\n• `!warn @User Spam --to 1j`", inline=False)
+ embed.add_field(name="Durées", value="`s` = secondes, `m` = minutes (défaut), `h` = heures, `j` = jours\nExemple: `--to 10m` ou `--to 60s`", inline=False)
+ embed.add_field(name="Aliases", value="`!averto`, `!av`, `!avertissement`, `!warn`", inline=False)
+ msg = await channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+def create_warning_event(target_user, reason: str, staff_member):
+ event = ModerationEvent(
+ type='warning',
+ username=target_user.name,
+ discord_id=str(target_user.id),
+ created_at=datetime.now(timezone.utc),
+ reason=reason,
+ staff_id=str(staff_member.id),
+ staff_name=staff_member.name
+ )
+ db.session.add(event)
+ _commit_with_retry()
+
+def _commit_with_retry(max_retries: int = 5, base_delay: float = 0.1):
+ attempt = 0
+ while True:
+ try:
+ db.session.commit()
+ return
+ except Exception as e:
+ msg = str(e)
+ if 'database is locked' in msg.lower() and attempt < max_retries:
+ db.session.rollback()
+ delay = base_delay * (2 ** attempt)
+ time.sleep(delay)
+ attempt += 1
+ continue
+ db.session.rollback()
+ raise
+
+async def send_dm_to_warned_user(target_user, reason: str, guild_name: str):
+ try:
+ dm_embed = discord.Embed(
+ title="⚠️ Avertissement",
+ description=f"Vous avez reçu un avertissement sur le serveur **{guild_name}**",
+ color=discord.Color.orange(),
+ timestamp=datetime.now(timezone.utc)
+ )
+ if reason != "Sans raison":
+ dm_embed.add_field(name="📝 Raison", value=reason, inline=False)
+ dm_embed.add_field(name="ℹ️ Information", value="Si vous avez des questions concernant cet avertissement, vous pouvez contacter l'équipe de modération.", inline=False)
+ await target_user.send(embed=dm_embed)
+ return True
+ except discord.Forbidden:
+ logging.warning(f"Impossible d'envoyer un MP à {target_user.name} ({target_user.id}) - MPs désactivés")
+ return False
+ except Exception as e:
+ logging.error(f"Erreur lors de l'envoi du MP à {target_user.name} ({target_user.id}): {e}")
+ return False
+
+async def send_warning_confirmation(channel, target_user, reason: str, original_message: Message, bot, timeout_info: tuple = None):
+ local_now = _to_local(datetime.now(timezone.utc))
+ dm_sent = await send_dm_to_warned_user(target_user, reason, original_message.guild.name)
+
+ was_timed_out = timeout_info is not None and timeout_info[0]
+ timeout_duration = timeout_info[1] if timeout_info else None
+
+ title = "⚠️ Avertissement + ⏱️ Time out" if was_timed_out else "⚠️ Avertissement"
+ description = f"**{target_user.name}** (`{target_user.name}`) a reçu un avertissement"
+ if was_timed_out:
+ description += f" et a été time out ({format_timeout_duration(timeout_duration)})"
+
+ embed = discord.Embed(
+ title=title,
+ description=description,
+ color=discord.Color.orange(),
+ timestamp=datetime.now(timezone.utc)
+ )
+ embed.add_field(name="👤 Utilisateur", value=f"{target_user.mention}\n`{target_user.id}`", inline=True)
+ embed.add_field(name="🛡️ Modérateur", value=f"{original_message.author.mention}\n`{original_message.author.name}`", inline=True)
+ embed.add_field(name="📅 Date et heure", value=local_now.strftime('%d/%m/%Y à %H:%M'), inline=True)
+ if reason != "Sans raison":
+ embed.add_field(name="📝 Raison", value=reason, inline=False)
+
+ if dm_sent:
+ embed.add_field(name="✅ Message privé", value="L'utilisateur a été notifié par MP", inline=False)
+ else:
+ embed.add_field(name="⚠️ Message privé", value=f"Il faut contacter {target_user.mention} pour l'informer de cet avertissement (MPs désactivés). {original_message.author.mention}", inline=False)
+
+ embed.set_footer(text=f"ID: {target_user.id} • Serveur: {original_message.guild.name}")
+
+ await send_to_moderation_log_channel(bot, embed)
+ await safe_delete_message(original_message)
+
+async def handle_warning_command(message: Message, bot):
+ parts = message.content.split(maxsplit=2)
+ if not has_staff_role(message.author.roles):
+ await send_access_denied(message.channel)
+ elif len(parts) < 2:
+ await send_warning_usage(message.channel)
+ else:
+ target_user, reason, timeout_seconds = await parse_target_user_and_reason(message, bot, parts)
+ if not target_user:
+ await send_user_not_found(message.channel)
+ else:
+ await _process_warning_success(message, target_user, reason, bot, timeout_seconds)
+
+async def _process_warning_success(message: Message, target_user, reason: str, bot, timeout_seconds: int = None):
+ create_warning_event(target_user, reason, message.author)
+
+ timeout_info = None
+ if timeout_seconds:
+ member_obj = message.guild.get_member(target_user.id)
+ if member_obj:
+ try:
+ until = discord.utils.utcnow() + timedelta(seconds=timeout_seconds)
+ await member_obj.timeout(until, reason=reason)
+ timeout_info = (True, timeout_seconds)
+
+ timeout_event = ModerationEvent(
+ type='timeout',
+ username=target_user.name,
+ discord_id=str(target_user.id),
+ created_at=datetime.now(timezone.utc),
+ reason=reason,
+ staff_id=str(message.author.id),
+ staff_name=message.author.name,
+ duration=timeout_seconds
+ )
+ db.session.add(timeout_event)
+ _commit_with_retry()
+ except discord.Forbidden:
+ logging.error(f"Permissions insuffisantes pour timeout {target_user.name}")
+ except Exception as e:
+ logging.error(f"Erreur lors du timeout de {target_user.name}: {e}")
+
+ await send_warning_confirmation(message.channel, target_user, reason, message, bot, timeout_info)
+
+async def send_timeout_usage(channel):
+ embed = discord.Embed(
+ title="📋 Utilisation de la commande",
+ description="**Syntaxe :** `!to @utilisateur durée raison` ou `!timeout @utilisateur durée raison`",
+ color=discord.Color.blue()
+ )
+ embed.add_field(name="Exemples", value="• `!to @User 10m Spam`\n• `!timeout @User 1h Comportement inapproprié`\n• `!to @User 30s Flood`\n• `!timeout @User 1j Toxicité`", inline=False)
+ embed.add_field(name="Durées", value="`s` = secondes, `m` = minutes (défaut), `h` = heures, `j` = jours\nExemple: `10m`, `1h`, `60s`", inline=False)
+ embed.add_field(name="Aliases", value="`!to`, `!timeout`", inline=False)
+ msg = await channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+def parse_timeout_from_args(duration_str: str):
+ match = re.match(r'^(\d+)([smhj])?$', duration_str.lower())
+ if not match:
+ return None
+
+ value = int(match.group(1))
+ unit = match.group(2) or 'm'
+
+ if unit == 's':
+ return value
+ elif unit == 'm':
+ return value * 60
+ elif unit == 'h':
+ return value * 3600
+ elif unit == 'j':
+ return value * 86400
+ return None
+
+async def parse_timeout_target_and_params(message, bot, parts: list):
+ if len(parts) < 3:
+ return None, None, None
+
+ if message.mentions:
+ target_user = message.mentions[0]
+ timeout_seconds = parse_timeout_from_args(parts[2])
+ reason = " ".join(parts[3:]) if len(parts) > 3 else "Sans raison"
+ return target_user, timeout_seconds, reason
+
+ try:
+ user_id = int(parts[1])
+ target_user = await bot.fetch_user(user_id)
+ timeout_seconds = parse_timeout_from_args(parts[2])
+ reason = " ".join(parts[3:]) if len(parts) > 3 else "Sans raison"
+ return target_user, timeout_seconds, reason
+ except (ValueError, discord.NotFound):
+ return None, None, None
+
+async def send_timeout_confirmation(channel, target_user, reason: str, timeout_seconds: int, original_message: Message, bot):
+ local_now = _to_local(datetime.now(timezone.utc))
+
+ embed = discord.Embed(
+ title="⏱️ Time out",
+ description=f"**{target_user.name}** (`{target_user.name}`) a été exclu temporairement ({format_timeout_duration(timeout_seconds)})",
+ color=discord.Color.orange(),
+ timestamp=datetime.now(timezone.utc)
+ )
+ embed.add_field(name="👤 Utilisateur", value=f"{target_user.mention}\n`{target_user.id}`", inline=True)
+ embed.add_field(name="🛡️ Modérateur", value=f"{original_message.author.mention}\n`{original_message.author.name}`", inline=True)
+ embed.add_field(name="📅 Date et heure", value=local_now.strftime('%d/%m/%Y à %H:%M'), inline=True)
+ embed.add_field(name="⏱️ Durée", value=format_timeout_duration(timeout_seconds), inline=True)
+ if reason != "Sans raison":
+ embed.add_field(name="📝 Raison", value=reason, inline=False)
+
+ embed.set_footer(text=f"ID: {target_user.id} • Serveur: {original_message.guild.name}")
+
+ await send_to_moderation_log_channel(bot, embed)
+ await safe_delete_message(original_message)
+
+async def send_invalid_timeout_duration(channel):
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description="Durée invalide. Utilisez un format valide comme `10m`, `1h`, `60s`, etc.",
+ color=discord.Color.red()
+ )
+ msg = await channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+async def handle_timeout_command(message: Message, bot):
+ parts = message.content.split()
+ if not has_staff_role(message.author.roles):
+ await send_access_denied(message.channel)
+ elif len(parts) < 3:
+ await send_timeout_usage(message.channel)
+ else:
+ target_user, timeout_seconds, reason = await parse_timeout_target_and_params(message, bot, parts)
+ if not target_user:
+ await send_user_not_found(message.channel)
+ elif not timeout_seconds:
+ await send_invalid_timeout_duration(message.channel)
+ else:
+ await _process_timeout_success(message, target_user, reason, timeout_seconds, bot)
+
+async def _process_timeout_success(message: Message, target_user, reason: str, timeout_seconds: int, bot):
+ member_obj = message.guild.get_member(target_user.id)
+ if not member_obj:
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description="L'utilisateur n'est pas membre du serveur.",
+ color=discord.Color.red()
+ )
+ msg = await message.channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+ return
+
+ try:
+ until = discord.utils.utcnow() + timedelta(seconds=timeout_seconds)
+ await member_obj.timeout(until, reason=reason)
+
+ timeout_event = ModerationEvent(
+ type='timeout',
+ username=target_user.name,
+ discord_id=str(target_user.id),
+ created_at=datetime.now(timezone.utc),
+ reason=reason,
+ staff_id=str(message.author.id),
+ staff_name=message.author.name,
+ duration=timeout_seconds
+ )
+ db.session.add(timeout_event)
+ _commit_with_retry()
+
+ await send_timeout_confirmation(message.channel, target_user, reason, timeout_seconds, message, bot)
+ except discord.Forbidden:
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description="Je n'ai pas les permissions nécessaires pour exclure cet utilisateur.",
+ color=discord.Color.red()
+ )
+ msg = await message.channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+ except Exception as e:
+ logging.error(f"Erreur lors du timeout de {target_user.name}: {e}")
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description=f"Une erreur est survenue lors de l'exclusion : {str(e)}",
+ color=discord.Color.red()
+ )
+ msg = await message.channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+async def send_remove_warning_usage(channel):
+ embed = discord.Embed(
+ title="📋 Utilisation de la commande",
+ description="**Syntaxe :** `!delaverto `",
+ color=discord.Color.blue()
+ )
+ embed.add_field(name="Exemples", value="• `!delaverto 5`\n• `!removewarn 12`", inline=False)
+ embed.add_field(name="Aliases", value="`!delaverto`, `!removewarn`, `!delwarn`", inline=False)
+ msg = await channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+async def send_invalid_event_id(channel):
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description="L'ID doit être un nombre entier.",
+ color=discord.Color.red()
+ )
+ msg = await channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+async def send_event_not_found(channel, event_id: int):
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description=f"Aucun événement de modération trouvé avec l'ID `{event_id}`.",
+ color=discord.Color.red()
+ )
+ msg = await channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+def delete_moderation_event(event: ModerationEvent):
+ db.session.delete(event)
+ db.session.commit()
+
+async def send_event_deleted_confirmation(channel, event: ModerationEvent, moderator, original_message: Message):
+ embed = discord.Embed(
+ title="✅ Événement supprimé",
+ description=f"L'événement de type **{event.type}** pour **{event.username}** (ID: {event.id}) a été supprimé.",
+ color=discord.Color.green(),
+ timestamp=datetime.now(timezone.utc)
+ )
+ embed.add_field(name="🛡️ Modérateur", value=f"{moderator.name}\n`{moderator.id}`", inline=True)
+ embed.set_footer(text="Mamie Henriette")
+
+ msg = await channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+ await safe_delete_message(original_message)
+
+async def handle_remove_warning_command(message: Message, bot):
+ if not has_staff_role(message.author.roles):
+ await send_access_denied(message.channel)
+ return
+
+ parts = message.content.split(maxsplit=1)
+
+ if len(parts) < 2:
+ await send_remove_warning_usage(message.channel)
+ return
+
+ try:
+ event_id = int(parts[1])
+ except ValueError:
+ await send_invalid_event_id(message.channel)
+ return
+
+ event = ModerationEvent.query.filter_by(id=event_id).first()
+
+ if not event:
+ await send_event_not_found(message.channel, event_id)
+ return
+
+ delete_moderation_event(event)
+ await send_event_deleted_confirmation(message.channel, event, message.author, message)
+
+def get_moderation_events(user_filter: str = None):
+ if user_filter:
+ return ModerationEvent.query.filter_by(discord_id=user_filter).order_by(ModerationEvent.created_at.desc()).all()
+ return ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
+
+async def send_no_events_found(channel):
+ embed = discord.Embed(
+ title="📋 Liste des événements",
+ description="Aucun événement de modération trouvé.",
+ color=discord.Color.blue()
+ )
+ msg = await channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+def create_events_list_embed(events: list, page_num: int, per_page: int):
+ start = page_num * per_page
+ end = start + per_page
+ page_events = events[start:end]
+ max_page = (len(events) - 1) // per_page
+
+ embed = discord.Embed(
+ title="📋 Liste des événements de modération",
+ description=f"Total : {len(events)} événement(s)",
+ color=discord.Color.blue(),
+ timestamp=datetime.now(timezone.utc)
+ )
+
+ for event in page_events:
+ local_dt = _to_local(event.created_at)
+ date_str = local_dt.strftime('%d/%m/%Y %H:%M') if local_dt else 'N/A'
+ embed.add_field(
+ name=f"ID {event.id} - {event.type.upper()} - {event.username}",
+ value=f"**Discord ID:** `{event.discord_id}`\n**Date:** {date_str}\n**Raison:** {event.reason}\n**Staff:** {event.staff_name}",
+ inline=False
+ )
+
+ embed.set_footer(text=f"Page {page_num + 1}/{max_page + 1}")
+ return embed
+
+async def add_pagination_reactions(msg, max_page: int):
+ if max_page > 0:
+ await msg.add_reaction('⬅️')
+ await msg.add_reaction('➡️')
+ await msg.add_reaction('❌')
+
+async def handle_pagination_loop(msg, bot, message_author, events: list, per_page: int):
+ page = 0
+ max_page = (len(events) - 1) // per_page
+
+ def check(reaction, user):
+ return user == message_author and str(reaction.emoji) in ['⬅️', '➡️', '❌'] and reaction.message.id == msg.id
+
+ while True:
+ try:
+ reaction, user = await bot.wait_for('reaction_add', timeout=60.0, check=check)
+
+ if str(reaction.emoji) == '❌':
+ await msg.delete()
+ break
+ elif str(reaction.emoji) == '➡️' and page < max_page:
+ page += 1
+ await msg.edit(embed=create_events_list_embed(events, page, per_page))
+ elif str(reaction.emoji) == '⬅️' and page > 0:
+ page -= 1
+ await msg.edit(embed=create_events_list_embed(events, page, per_page))
+
+ await msg.remove_reaction(reaction, user)
+ except:
+ break
+
+ try:
+ await msg.clear_reactions()
+ except:
+ pass
+
+async def handle_list_warnings_command(message: Message, bot):
+ if not has_staff_role(message.author.roles):
+ await send_access_denied(message.channel)
+ return
+
+ parts = message.content.split(maxsplit=1)
+ user_filter = str(message.mentions[0].id) if len(parts) > 1 and message.mentions else None
+
+ events = get_moderation_events(user_filter)
+
+ if not events:
+ await send_no_events_found(message.channel)
+ return
+
+ per_page = 5
+ max_page = (len(events) - 1) // per_page
+
+ msg = await message.channel.send(embed=create_events_list_embed(events, 0, per_page))
+ await add_pagination_reactions(msg, max_page)
+ await handle_pagination_loop(msg, bot, message.author, events, per_page)
+ await safe_delete_message(message)
+
+async def handle_ban_command(message: Message, bot):
+ parts = message.content.split(maxsplit=2)
+ if not has_staff_role(message.author.roles):
+ await send_access_denied(message.channel)
+ elif len(parts) < 2:
+ await _send_ban_usage(message.channel)
+ else:
+ target_user, reason = await _parse_ban_target_and_reason(message, bot, parts)
+ if not target_user:
+ await _send_user_not_found_for_ban(message.channel)
+ else:
+ await _process_ban_success(message, target_user, reason, bot)
+
+async def _send_ban_usage(channel):
+ embed = discord.Embed(
+ title="📋 Utilisation de la commande",
+ description="**Syntaxe :** `!ban @utilisateur [raison]` ou `!ban [raison]`",
+ color=discord.Color.blue()
+ )
+ embed.add_field(name="Exemples", value="• `!ban @User Spam répété`\n• `!ban 123456789012345678 Comportement toxique`", inline=False)
+ msg = await channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+async def _parse_ban_target_and_reason(message: Message, bot, parts: list):
+ if message.mentions:
+ return message.mentions[0], (parts[2] if len(parts) > 2 else "Sans raison")
+ try:
+ user_id = int(parts[1])
+ user = await bot.fetch_user(user_id)
+ return user, (parts[2] if len(parts) > 2 else "Sans raison")
+ except (ValueError, discord.NotFound):
+ return None, None
+
+async def _send_user_not_found_for_ban(channel):
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description="Utilisateur introuvable. Vérifiez la mention ou l'ID Discord.",
+ color=discord.Color.red()
+ )
+ msg = await channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+def _create_ban_event(target_user, reason: str, staff_member):
+ event = ModerationEvent(
+ type='ban',
+ username=target_user.name,
+ discord_id=str(target_user.id),
+ created_at=datetime.now(timezone.utc),
+ reason=reason,
+ staff_id=str(staff_member.id),
+ staff_name=staff_member.name
+ )
+ db.session.add(event)
+ _commit_with_retry()
+ return event
+
+async def _process_ban_success(message: Message, target_user, reason: str, bot):
+ member = message.guild.get_member(target_user.id)
+ joined_days = None
+ if member and member.joined_at:
+ delta = datetime.now(timezone.utc) - (member.joined_at if member.joined_at.tzinfo else member.joined_at.replace(tzinfo=timezone.utc))
+ joined_days = delta.days
+ try:
+ await message.guild.ban(target_user, reason=reason)
+ except discord.Forbidden:
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description="Je n'ai pas les permissions nécessaires pour bannir cet utilisateur.",
+ color=discord.Color.red()
+ )
+ msg = await message.channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+ return
+
+ event = _create_ban_event(target_user, reason, message.author)
+
+ local_now = _to_local(datetime.now(timezone.utc))
+ embed = discord.Embed(
+ title="🔨 Bannissement",
+ description=f"**{target_user.name}** (`{target_user.name}`) a été banni du serveur",
+ color=discord.Color.red(),
+ timestamp=datetime.now(timezone.utc)
+ )
+ embed.add_field(name="👤 Utilisateur", value=f"{target_user.mention}\n`{target_user.id}`", inline=True)
+ embed.add_field(name="🛡️ Modérateur", value=f"{message.author.mention}\n`{message.author.name}`", inline=True)
+ embed.add_field(name="📅 Date et heure", value=local_now.strftime('%d/%m/%Y à %H:%M'), inline=True)
+ if joined_days is not None:
+ embed.add_field(name="⏱️ Membre depuis", value=format_days_to_age(joined_days), inline=True)
+ if reason != "Sans raison":
+ embed.add_field(name="📝 Raison", value=reason, inline=False)
+ embed.set_footer(text=f"ID: {target_user.id} • Serveur: {message.guild.name}")
+
+ await send_to_moderation_log_channel(bot, embed)
+ await safe_delete_message(message)
+async def handle_unban_command(message: Message, bot):
+ parts = message.content.split(maxsplit=2)
+ if not has_staff_role(message.author.roles):
+ await send_access_denied(message.channel)
+ elif len(parts) < 2:
+ await _send_unban_usage(message.channel)
+ else:
+ target_user, discord_id, reason = await _parse_unban_target_and_reason(message, bot, parts)
+ if not discord_id:
+ await _send_unban_invalid_id(message.channel)
+ else:
+ await _process_unban_success(message, bot, target_user, discord_id, reason)
+
+async def _send_unban_usage(channel):
+ embed = discord.Embed(
+ title="📋 Utilisation de la commande",
+ description="**Syntaxe :** `!unban ` ou `!unban # [raison]`",
+ color=discord.Color.blue()
+ )
+ embed.add_field(name="Exemples", value="• `!unban 123456789012345678`\n• `!unban #5 Appel accepté`", inline=False)
+ msg = await channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+async def _parse_unban_target_and_reason(message: Message, bot, parts: list):
+ reason = parts[2] if len(parts) > 2 else "Sans raison"
+ target_user = None
+ discord_id = None
+ if parts[1].startswith('#'):
+ try:
+ sanction_id = int(parts[1][1:])
+ evt = ModerationEvent.query.filter_by(id=sanction_id, type='ban').first()
+ if not evt:
+ return None, None, reason
+ discord_id = evt.discord_id
+ try:
+ target_user = await bot.fetch_user(int(discord_id))
+ except discord.NotFound:
+ pass
+ except ValueError:
+ return None, None, reason
+ else:
+ try:
+ discord_id = parts[1]
+ target_user = await bot.fetch_user(int(discord_id))
+ except (ValueError, discord.NotFound):
+ return None, None, reason
+ return target_user, discord_id, reason
+
+async def _send_unban_invalid_id(channel):
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description="ID Discord invalide ou utilisateur introuvable.",
+ color=discord.Color.red()
+ )
+ msg = await channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+async def _process_unban_success(message: Message, bot, target_user, discord_id: str, reason: str):
+ try:
+ await message.guild.unban(discord.Object(id=int(discord_id)), reason=reason)
+ except discord.NotFound:
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description="Cet utilisateur n'est pas banni.",
+ color=discord.Color.red()
+ )
+ msg = await message.channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+ return
+ except discord.Forbidden:
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description="Je n'ai pas les permissions nécessaires pour débannir cet utilisateur.",
+ color=discord.Color.red()
+ )
+ msg = await message.channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+ return
+
+ username = target_user.name if target_user else f"ID: {discord_id}"
+ create = ModerationEvent(
+ type='unban',
+ username=username,
+ discord_id=discord_id,
+ created_at=datetime.now(timezone.utc),
+ reason=reason,
+ staff_id=str(message.author.id),
+ staff_name=message.author.name
+ )
+ db.session.add(create)
+ _commit_with_retry()
+
+ try:
+ asyncio.create_task(_send_unban_invite(message, bot, target_user, discord_id))
+ except:
+ pass
+
+ local_now = _to_local(datetime.now(timezone.utc))
+ embed = discord.Embed(
+ title="✅ Débannissement",
+ description=f"**{username}** (`{username}`) a été débanni du serveur",
+ color=discord.Color.green(),
+ timestamp=datetime.now(timezone.utc)
+ )
+ user_mention = target_user.mention if target_user else username
+ embed.add_field(name="👤 Utilisateur", value=f"{user_mention}\n`{discord_id}`", inline=True)
+ embed.add_field(name="🛡️ Modérateur", value=f"{message.author.mention}\n`{message.author.name}`", inline=True)
+ embed.add_field(name="📅 Date et heure", value=local_now.strftime('%d/%m/%Y à %H:%M'), inline=True)
+ if reason != "Sans raison":
+ embed.add_field(name="📝 Raison", value=reason, inline=False)
+ embed.set_footer(text=f"ID: {discord_id} • Serveur: {message.guild.name}")
+
+ await send_to_moderation_log_channel(bot, embed)
+ await safe_delete_message(message)
+
+async def _send_unban_invite(message: Message, bot, target_user, discord_id: str):
+ try:
+ user_obj = target_user or await bot.fetch_user(int(discord_id))
+ channel = None
+ try:
+ channel_id = ConfigurationHelper().getIntValue('welcome_channel_id')
+ if channel_id:
+ channel = bot.get_channel(channel_id) or message.guild.get_channel(channel_id)
+ except:
+ pass
+ if not channel:
+ me = message.guild.me or message.guild.get_member(bot.user.id)
+ for ch in message.guild.text_channels:
+ try:
+ perms = ch.permissions_for(me) if me else None
+ if not perms or not perms.create_instant_invite:
+ continue
+ channel = ch
+ break
+ except:
+ continue
+ if not channel:
+ channel = message.guild.system_channel or message.channel
+ invite = None
+ try:
+ invite = await channel.create_invite(max_age=86400, max_uses=1, unique=True, reason='Invitation automatique après débannissement')
+ except Exception as e:
+ logging.warning(f"[UNBAN] Échec création d'invitation sur #{channel and channel.name}: {e}")
+ return
+ if user_obj and invite:
+ try:
+ msg = f"Tu as été débanni de {message.guild.name}. Voici une invitation pour revenir : {invite.url}"
+ await user_obj.send(msg)
+ except Exception as e:
+ logging.warning(f"[UNBAN] Impossible d'envoyer un MP à {user_obj} ({user_obj.id}): {e}")
+ try:
+ await message.author.send(f"Impossible d'envoyer un MP à {user_obj} pour l'unban. Voici l'invitation à lui transmettre : {invite.url}")
+ except:
+ pass
+ except:
+ pass
+
+async def handle_ban_list_command(message: Message, bot):
+ if not has_staff_role(message.author.roles):
+ embed = discord.Embed(
+ title="❌ Accès refusé",
+ description="Vous n'avez pas les permissions nécessaires pour utiliser cette commande.",
+ color=discord.Color.red()
+ )
+ msg = await message.channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+ return
+
+ # Récupérer la liste des bannis
+ bans = []
+ try:
+ async for entry in message.guild.bans(limit=None):
+ bans.append(entry)
+ except TypeError:
+ try:
+ bans = await message.guild.bans()
+ except Exception:
+ bans = []
+ except Exception:
+ bans = []
+
+ if not bans:
+ embed = discord.Embed(
+ title="🔨 Utilisateurs bannis",
+ description="Aucun utilisateur banni sur ce serveur.",
+ color=discord.Color.blue()
+ )
+ msg = await message.channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+ await safe_delete_message(message)
+ return
+
+ page = 0
+ per_page = 10
+ max_page = (len(bans) - 1) // per_page
+
+ def create_banlist_embed(page_num: int):
+ start = page_num * per_page
+ end = start + per_page
+ page_bans = bans[start:end]
+ embed = discord.Embed(
+ title="🔨 Utilisateurs bannis",
+ description=f"Total : {len(bans)} utilisateur(s) banni(s)",
+ color=discord.Color.red(),
+ timestamp=datetime.now(timezone.utc)
+ )
+ for entry in page_bans:
+ user = entry.user
+ reason = entry.reason or 'Sans raison'
+ embed.add_field(
+ name=f"{user.name} ({user.id})",
+ value=f"Raison: {reason}",
+ inline=False
+ )
+ embed.set_footer(text=f"Page {page_num + 1}/{max_page + 1}")
+ return embed
+
+ msg = await message.channel.send(embed=create_banlist_embed(page))
+ if max_page > 0:
+ await msg.add_reaction('⬅️')
+ await msg.add_reaction('➡️')
+ await msg.add_reaction('❌')
+
+ def check(reaction, user):
+ return user == message.author and str(reaction.emoji) in ['⬅️', '➡️', '❌'] and reaction.message.id == msg.id
+
+ while True:
+ try:
+ reaction, user = await bot.wait_for('reaction_add', timeout=60.0, check=check)
+ if str(reaction.emoji) == '❌':
+ await msg.delete()
+ break
+ elif str(reaction.emoji) == '➡️' and page < max_page:
+ page += 1
+ await msg.edit(embed=create_banlist_embed(page))
+ elif str(reaction.emoji) == '⬅️' and page > 0:
+ page -= 1
+ await msg.edit(embed=create_banlist_embed(page))
+ await msg.remove_reaction(reaction, user)
+ except Exception:
+ break
+
+ try:
+ await msg.clear_reactions()
+ except Exception:
+ pass
+
+ await safe_delete_message(message)
+
+async def handle_staff_help_command(message: Message, bot):
+ is_staff = has_staff_role(message.author.roles)
+
+ embed = discord.Embed(
+ title="📚 Aide - Commandes disponibles",
+ description="Liste de toutes les commandes disponibles",
+ color=discord.Color.blurple(),
+ timestamp=datetime.now(timezone.utc)
+ )
+ embed.set_thumbnail(url=bot.user.display_avatar.url)
+ embed.set_footer(text=f"Demandé par {message.author.name}")
+
+ public_commands = []
+
+ if ConfigurationHelper().getValue('proton_db_enable_enable'):
+ public_commands.append(
+ "**🎮 ProtonDB**\n"
+ "• `!protondb nom du jeu` ou `!pdb nom du jeu`\n"
+ "Recherche un jeu sur ProtonDB pour vérifier sa compatibilité Linux\n"
+ "Ex: `!pdb Elden Ring`"
+ )
+
+ from database.models import Commande
+ custom_commands = Commande.query.filter_by(discord_enable=True).all()
+ if custom_commands:
+ commands_list = []
+ for cmd in custom_commands:
+ commands_list.append(f"• `{cmd.trigger}`")
+ custom_text = "\n".join(commands_list[:10])
+ if len(custom_commands) > 10:
+ custom_text += f"\n*... et {len(custom_commands) - 10} autres*"
+ public_commands.append(f"**🤖 Commandes personnalisées**\n{custom_text}")
+
+ if public_commands:
+ for cmd_text in public_commands:
+ embed.add_field(name="\u200b", value=cmd_text, inline=False)
+ else:
+ embed.add_field(
+ name="📝 Commandes publiques",
+ value="Aucune commande publique configurée pour le moment.",
+ inline=False
+ )
+
+ if is_staff:
+ embed.add_field(
+ name="━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
+ value="**🛠️ COMMANDES STAFF**",
+ inline=False
+ )
+
+ if ConfigurationHelper().getValue('moderation_enable'):
+ value = (
+ "**Avertissements:**\n"
+ "• `!warn @utilisateur raison`\n"
+ " *Alias: !averto, !av, !avertissement*\n"
+ " Donne un avertissement\n"
+ "• `!warn @utilisateur raison --to durée`\n"
+ " Avertissement + time out temporaire\n\n"
+ "**Time out uniquement:**\n"
+ "• `!to @utilisateur durée raison`\n"
+ " *Alias: !timeout*\n"
+ " Time out (sans avertissement)\n"
+ " *Durées: 10s, 5m, 1h, 2j*\n\n"
+ "**Gestion:**\n"
+ "• `!delaverto id` - Supprime un événement\n"
+ "• `!warnings [@utilisateur]` - Liste les événements\n\n"
+ "**Exemples:**\n"
+ "`!warn @User Spam`\n"
+ "`!warn @User Flood --to 10m` (averto + timeout)\n"
+ "`!to @User 5m Spam` (timeout seul)\n"
+ "`!warnings @User`"
+ )
+ embed.add_field(name="⚠️ Avertissements & Time out", value=value, inline=False)
+ embed.add_field(
+ name="🔎 Inspection",
+ value=("• `!inspect @utilisateur` ou `!inspect id`\n"
+ "Affiche les infos détaillées et l'historique de modération\n"
+ "Ex: `!inspect @User`"),
+ inline=False
+ )
+
+ if ConfigurationHelper().getValue('moderation_ban_enable'):
+ value = (
+ "• `!ban @utilisateur raison`\n"
+ " Bannit définitivement un utilisateur\n"
+ "• `!unban discord_id` ou `!unban #sanction_id raison`\n"
+ " Révoque le ban et envoie une invitation\n"
+ "• `!banlist`\n"
+ " Affiche la liste des utilisateurs bannis\n"
+ "Exemples:\n"
+ "`!ban @User Comportement toxique répété`\n"
+ "`!unban 123456789012345678 Erreur de modération`\n"
+ "`!unban #5 Appel accepté`"
+ )
+ embed.add_field(name="🔨 Bannissement", value=value, inline=False)
+
+ if ConfigurationHelper().getValue('moderation_kick_enable'):
+ value = (
+ "• `!kick @utilisateur raison` ou `!kick raison`\n"
+ " Expulse temporairement un utilisateur du serveur\n"
+ "Exemples: `!kick @User Spam de liens` ou `!kick 123456789012345678 Spam`"
+ )
+ embed.add_field(name="👢 Expulsion", value=value, inline=False)
+
+ embed.add_field(
+ name="💬 Autres",
+ value=(
+ "• `!say #channel message`\n"
+ " Envoie un message en tant que bot\n"
+ " Ex: `!say #annonces Nouvelle fonctionnalité !`"
+ ),
+ inline=False
+ )
+
+ try:
+ sent = await message.channel.send(embed=embed)
+ if is_staff:
+ asyncio.create_task(delete_after_delay(sent))
+ except Exception:
+ pass
+ await safe_delete_message(message)
+
+async def handle_kick_command(message: Message, bot):
+ parts = message.content.split(maxsplit=2)
+ if not has_staff_role(message.author.roles):
+ await send_access_denied(message.channel)
+ elif len(parts) < 2:
+ await _send_kick_usage(message.channel)
+ else:
+ target_user, reason = await _parse_kick_target_and_reason(message, bot, parts)
+ if not target_user:
+ await _send_user_not_found_for_kick(message.channel)
+ else:
+ await _process_kick_success(message, target_user, reason, bot)
+
+async def _send_kick_usage(channel):
+ embed = discord.Embed(
+ title="📋 Utilisation de la commande",
+ description="**Syntaxe :** `!kick @utilisateur [raison]` ou `!kick [raison]`",
+ color=discord.Color.blue()
+ )
+ embed.add_field(name="Exemples", value="• `!kick @User Spam dans le chat`\n• `!kick 123456789012345678 Comportement inapproprié`", inline=False)
+ msg = await channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+async def _parse_kick_target_and_reason(message: Message, bot, parts: list):
+ if message.mentions:
+ return message.mentions[0], (parts[2] if len(parts) > 2 else "Sans raison")
+ try:
+ user_id = int(parts[1])
+ user = await bot.fetch_user(user_id)
+ return user, (parts[2] if len(parts) > 2 else "Sans raison")
+ except (ValueError, discord.NotFound):
+ return None, None
+
+async def _send_user_not_found_for_kick(channel):
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description="Utilisateur introuvable. Vérifiez la mention ou l'ID Discord.",
+ color=discord.Color.red()
+ )
+ msg = await channel.send(embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+async def _process_kick_success(message: Message, target_member, reason: str, bot):
+ member_obj = message.guild.get_member(target_member.id)
+ if not member_obj:
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description="L'utilisateur n'est pas membre du serveur.",
+ color=discord.Color.red()
+ )
+ msg = await message.channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+ return
+ joined_days = None
+ if member_obj.joined_at:
+ delta = datetime.now(timezone.utc) - (member_obj.joined_at if member_obj.joined_at.tzinfo else member_obj.joined_at.replace(tzinfo=timezone.utc))
+ joined_days = delta.days
+ try:
+ await message.guild.kick(member_obj, reason=reason)
+ except discord.Forbidden:
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description="Je n'ai pas les permissions nécessaires pour expulser cet utilisateur.",
+ color=discord.Color.red()
+ )
+ msg = await message.channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+ return
+ create = ModerationEvent(
+ type='kick',
+ username=target_member.name,
+ discord_id=str(target_member.id),
+ created_at=datetime.now(timezone.utc),
+ reason=reason,
+ staff_id=str(message.author.id),
+ staff_name=message.author.name
+ )
+ db.session.add(create)
+ _commit_with_retry()
+
+ local_now = _to_local(datetime.now(timezone.utc))
+ embed = discord.Embed(
+ title="👢 Expulsion",
+ description=f"**{target_member.name}** (`{target_member.name}`) a été expulsé du serveur",
+ color=discord.Color.orange(),
+ timestamp=datetime.now(timezone.utc)
+ )
+ embed.add_field(name="👤 Utilisateur", value=f"{target_member.mention}\n`{target_member.id}`", inline=True)
+ embed.add_field(name="🛡️ Modérateur", value=f"{message.author.mention}\n`{message.author.name}`", inline=True)
+ embed.add_field(name="📅 Date et heure", value=local_now.strftime('%d/%m/%Y à %H:%M'), inline=True)
+ if joined_days is not None:
+ embed.add_field(name="⏱️ Membre depuis", value=format_days_to_age(joined_days), inline=True)
+ if reason != "Sans raison":
+ embed.add_field(name="📝 Raison", value=reason, inline=False)
+ embed.set_footer(text=f"ID: {target_member.id} • Serveur: {message.guild.name}")
+
+ await send_to_moderation_log_channel(bot, embed)
+ await safe_delete_message(message)
+
+def format_days_to_age(days: int) -> str:
+ if days >= 365:
+ years = days // 365
+ remaining_days = days % 365
+ if remaining_days > 0:
+ return f"{years} an{'s' if years > 1 else ''} et {remaining_days} jour{'s' if remaining_days > 1 else ''}"
+ return f"{years} an{'s' if years > 1 else ''}"
+ return f"{days} jour{'s' if days > 1 else ''}"
+
+async def get_member_join_info(guild, member_id: int):
+ member = guild.get_member(member_id)
+ if not member or not member.joined_at:
+ return None, None
+
+ join_date = member.joined_at
+ days_on_server = (datetime.now(timezone.utc) - join_date).days
+ return join_date, days_on_server
+
+def get_account_age(user):
+ if not user.created_at:
+ return None
+ account_age = (datetime.now(timezone.utc) - user.created_at).days
+ return account_age
+
+def get_user_moderation_history(discord_id: str):
+ events = ModerationEvent.query.filter_by(discord_id=discord_id).order_by(ModerationEvent.created_at.desc()).all()
+
+ warnings = [e for e in events if e.type == 'warning']
+ kicks = [e for e in events if e.type == 'kick']
+ bans = [e for e in events if e.type == 'ban']
+
+ return warnings, kicks, bans
+
+async def send_inspect_usage(channel):
+ embed = discord.Embed(
+ title="📋 Utilisation de la commande",
+ description="**Syntaxe :** `!inspect @utilisateur` ou `!inspect `",
+ color=discord.Color.blue()
+ )
+ embed.add_field(name="Exemples", value="• `!inspect @User`\n• `!inspect 123456789012345678`", inline=False)
+ msg = await channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+
+async def parse_target_user(message: Message, bot, parts: list):
+ if message.mentions:
+ return message.mentions[0]
+
+ try:
+ user_id = int(parts[1])
+ return await bot.fetch_user(user_id)
+ except (ValueError, discord.NotFound):
+ return None
+
+def create_inspect_embed(user, member, join_date, days_on_server, account_age, warnings, kicks, bans, invite_info):
+ embed = discord.Embed(
+ title=f"🔍 Inspection de {user.name}",
+ color=discord.Color.blue(),
+ timestamp=datetime.now(timezone.utc)
+ )
+
+ embed.set_thumbnail(url=user.display_avatar.url)
+ embed.add_field(name="👤 Utilisateur", value=f"{user.mention}\n`{user.id}`", inline=True)
+
+ if account_age is not None:
+ embed.add_field(
+ name="📅 Compte créé",
+ value=f"{_to_local(user.created_at).strftime('%d/%m/%Y')}\n({format_days_to_age(account_age)})",
+ inline=True
+ )
+
+ if member and join_date:
+ embed.add_field(
+ name="📥 Rejoint le serveur",
+ value=f"{_to_local(join_date).strftime('%d/%m/%Y à %H:%M')}\n({format_days_to_age(days_on_server)})",
+ inline=True
+ )
+
+ if invite_info:
+ embed.add_field(name="🎫 Invitation", value=invite_info, inline=True)
+ else:
+ embed.add_field(name="🎫 Invitation", value="Inconnue", inline=True)
+
+ if member and join_date and user.created_at:
+ join_dt = join_date if join_date.tzinfo else join_date.replace(tzinfo=timezone.utc)
+ created_dt = user.created_at if user.created_at.tzinfo else user.created_at.replace(tzinfo=timezone.utc)
+ days_diff = (join_dt - created_dt).days
+ if days_diff < 7:
+ embed.add_field(
+ name="⚠️ Utilisateur suspect",
+ value=f"Raison de suspicion: Compte créé {days_diff} jour{'s' if days_diff > 1 else ''} avant de rejoindre le serveur",
+ inline=False
+ )
+
+ warning_text = f"⚠️ **{len(warnings)}** avertissement{'s' if len(warnings) > 1 else ''}"
+ kick_text = f"👢 **{len(kicks)}** expulsion{'s' if len(kicks) > 1 else ''}"
+ ban_text = f"🔨 **{len(bans)}** ban{'s' if len(bans) > 1 else ''}"
+
+ mod_history = f"{warning_text}\n{kick_text}\n{ban_text}"
+
+ if warnings or kicks or bans:
+ embed.add_field(name="📋 Historique de modération", value=mod_history, inline=False)
+
+ if warnings:
+ recent_warnings = warnings[:3]
+ warnings_detail = "\n".join([
+ f"• ID {w.id} - {_to_local(w.created_at).strftime('%d/%m/%Y')} - {w.reason[:50]}{'...' if len(w.reason) > 50 else ''}"
+ for w in recent_warnings
+ ])
+ if len(warnings) > 3:
+ warnings_detail += f"\n*... et {len(warnings) - 3} autre(s)*"
+ embed.add_field(name="⚠️ Derniers avertissements", value=warnings_detail, inline=False)
+ else:
+ embed.add_field(name="✅ Historique de modération", value="Aucun incident", inline=False)
+
+ embed.set_footer(text="Mamie Henriette")
+ return embed
+
+async def get_invite_info_for_user(bot, guild, user_id: int):
+ try:
+ from database import db
+ from sqlalchemy import text
+
+ result = db.session.execute(
+ text("SELECT invite_code, inviter_name FROM member_invites WHERE user_id = :user_id AND guild_id = :guild_id ORDER BY join_date DESC LIMIT 1"),
+ {'user_id': str(user_id), 'guild_id': str(guild.id)}
+ ).fetchone()
+
+ if result and result[0]:
+ invite_code = result[0]
+ inviter_name = result[1]
+ display_text = f"`{invite_code}`"
+ if inviter_name:
+ display_text += f" (créée par {inviter_name})"
+ return display_text
+
+ return None
+ except Exception as e:
+ logging.error(f'Erreur lors de la récupération de l\'invitation : {e}')
+ return None
+
+async def handle_inspect_command(message: Message, bot):
+ if not has_staff_role(message.author.roles):
+ await send_access_denied(message.channel)
+ return
+
+ parts = message.content.split(maxsplit=1)
+
+ if len(parts) < 2:
+ await send_inspect_usage(message.channel)
+ return
+
+ target_user = await parse_target_user(message, bot, parts)
+
+ if not target_user:
+ await send_user_not_found(message.channel)
+ return
+
+ member = message.guild.get_member(target_user.id)
+ join_date, days_on_server = await get_member_join_info(message.guild, target_user.id)
+ account_age = get_account_age(target_user)
+ warnings, kicks, bans = get_user_moderation_history(str(target_user.id))
+ invite_info = await get_invite_info_for_user(bot, message.guild, target_user.id)
+
+ embed = create_inspect_embed(
+ target_user,
+ member,
+ join_date,
+ days_on_server,
+ account_age,
+ warnings,
+ kicks,
+ bans,
+ invite_info
+ )
+
+ await message.channel.send(embed=embed)
+ await safe_delete_message(message)
+
+async def handle_say_command(message: Message, bot):
+ if not has_staff_role(message.author.roles):
+ await send_access_denied(message.channel)
+ return
+
+ parts = message.content.split(maxsplit=2)
+
+ if len(parts) < 3:
+ embed = discord.Embed(
+ title="📋 Utilisation de la commande",
+ description="**Syntaxe :** `!say #channel message`",
+ color=discord.Color.blue()
+ )
+ embed.add_field(name="Exemple", value="`!say #general Bonjour à tous !`", inline=False)
+ msg = await message.channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+ return
+
+ if not message.channel_mentions:
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description="Vous devez mentionner un canal avec #",
+ color=discord.Color.red()
+ )
+ msg = await message.channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+ return
+
+ target_channel = message.channel_mentions[0]
+ text_to_send = parts[2]
+
+ try:
+ await target_channel.send(text_to_send)
+ await safe_delete_message(message)
+ except discord.Forbidden:
+ embed = discord.Embed(
+ title="❌ Erreur",
+ description="Je n'ai pas les permissions pour écrire dans ce canal.",
+ color=discord.Color.red()
+ )
+ msg = await message.channel.send(embed=embed)
+ asyncio.create_task(delete_after_delay(msg))
+ except Exception as e:
+ logging.error(f"Erreur lors de l'envoi du message: {e}")
+
diff --git a/discordbot/welcome.py b/discordbot/welcome.py
new file mode 100644
index 0000000..d37d02d
--- /dev/null
+++ b/discordbot/welcome.py
@@ -0,0 +1,192 @@
+import discord
+import logging
+from database.helpers import ConfigurationHelper
+from discord import Member, TextChannel
+from datetime import datetime, timezone
+
+invite_cache = {}
+
+def replaceMessageVariables(message: str, member: Member) -> str:
+ replacements = {
+ '{member.mention}': member.mention,
+ '{member.name}': member.name,
+ '{member.display_name}': member.display_name,
+ '{member.id}': str(member.id),
+ '{server.name}': member.guild.name,
+ '{server.member_count}': str(member.guild.member_count)
+ }
+
+ for variable, value in replacements.items():
+ message = message.replace(variable, value)
+
+ return message
+
+async def updateInviteCache(guild):
+ try:
+ invites = await guild.invites()
+ invite_cache[guild.id] = {invite.code: invite.uses for invite in invites}
+ except:
+ pass
+
+async def getUsedInvite(guild):
+ try:
+ new_invites = await guild.invites()
+ for invite in new_invites:
+ old_uses = invite_cache.get(guild.id, {}).get(invite.code, 0)
+ if invite.uses > old_uses:
+ await updateInviteCache(guild)
+ invite_code = invite.code
+ inviter_name = invite.inviter.name if invite.inviter else None
+ display_text = f'`{invite_code}`'
+ if inviter_name:
+ display_text += f' (créée par {inviter_name})'
+ return (invite_code, inviter_name, display_text)
+ await updateInviteCache(guild)
+ except:
+ pass
+ return (None, None, 'Inconnue')
+
+async def sendWelcomeMessage(bot: discord.Client, member: Member):
+ config = ConfigurationHelper()
+
+ if not config.getValue('welcome_enable'):
+ return
+
+ channel_id = config.getIntValue('welcome_channel_id')
+ if not channel_id:
+ logging.warning('Canal de bienvenue non configuré')
+ return
+
+ channel = bot.get_channel(channel_id)
+ if not channel or not isinstance(channel, TextChannel):
+ logging.error(f'Canal de bienvenue {channel_id} introuvable')
+ return
+
+ welcome_message = config.getValue('welcome_message')
+ if not welcome_message:
+ welcome_message = 'Bienvenue sur le serveur !'
+
+ welcome_message = replaceMessageVariables(welcome_message, member)
+
+ invite_code, inviter_name, invite_display = await getUsedInvite(member.guild)
+
+ try:
+ from database import db
+ from sqlalchemy import text
+ db.session.execute(
+ text("INSERT INTO member_invites (user_id, guild_id, invite_code, inviter_name, join_date) VALUES (:user_id, :guild_id, :invite_code, :inviter_name, :join_date)"),
+ {
+ 'user_id': str(member.id),
+ 'guild_id': str(member.guild.id),
+ 'invite_code': invite_code,
+ 'inviter_name': inviter_name,
+ 'join_date': datetime.now(timezone.utc)
+ }
+ )
+ db.session.commit()
+ except Exception as e:
+ logging.error(f'Échec de la sauvegarde de l\'invitation : {e}')
+
+ embed = discord.Embed(
+ title='🎉 Nouveau membre !',
+ description=welcome_message,
+ color=discord.Color.green()
+ )
+
+ embed.set_thumbnail(url=member.display_avatar.url)
+ embed.add_field(name='Membre', value=member.mention, inline=True)
+ embed.add_field(name='Nombre de membres', value=str(member.guild.member_count), inline=True)
+ embed.add_field(name='Invitation utilisée', value=invite_display, inline=False)
+ embed.set_footer(text=f'ID: {member.id}')
+
+ try:
+ await channel.send(embed=embed)
+ logging.info(f'Message de bienvenue envoyé pour {member.name}')
+ except Exception as e:
+ logging.error(f'Échec de l\'envoi du message de bienvenue : {e}')
+
+def formatDuration(seconds: int) -> str:
+ days = seconds // 86400
+ hours = (seconds % 86400) // 3600
+ minutes = (seconds % 3600) // 60
+
+ parts = []
+ if days > 0:
+ parts.append(f'{days} jour{"s" if days > 1 else ""}')
+ if hours > 0:
+ parts.append(f'{hours} heure{"s" if hours > 1 else ""}')
+ if minutes > 0:
+ parts.append(f'{minutes} minute{"s" if minutes > 1 else ""}')
+
+ if not parts:
+ return 'moins d\'une minute'
+
+ return ' et '.join(parts)
+
+async def sendLeaveMessage(bot: discord.Client, member: Member):
+ config = ConfigurationHelper()
+
+ if not config.getValue('leave_enable'):
+ return
+
+ channel_id = config.getIntValue('leave_channel_id')
+ if not channel_id:
+ logging.warning('Canal de départ non configuré')
+ return
+
+ channel = bot.get_channel(channel_id)
+ if not channel or not isinstance(channel, TextChannel):
+ logging.error(f'Canal de départ {channel_id} introuvable')
+ return
+
+ leave_message = config.getValue('leave_message')
+ if not leave_message:
+ leave_message = 'Un membre a quitté le serveur.'
+
+ leave_message = replaceMessageVariables(leave_message, member)
+
+ now = datetime.now(timezone.utc)
+ duration_seconds = int((now - member.joined_at).total_seconds()) if member.joined_at else 0
+ duration_text = formatDuration(duration_seconds)
+
+ reason = 'Départ volontaire'
+ try:
+ async for entry in member.guild.audit_logs(limit=5):
+ if not (entry.target and entry.target.id == member.id):
+ continue
+
+ time_diff = (now - entry.created_at).total_seconds()
+ if time_diff > 3:
+ continue
+
+ if entry.action == discord.AuditLogAction.kick:
+ reason = f'Expulsé par {entry.user.mention}'
+ if entry.reason:
+ reason += f' - Raison: {entry.reason}'
+ break
+ elif entry.action == discord.AuditLogAction.ban:
+ reason = f'Banni par {entry.user.mention}'
+ if entry.reason:
+ reason += f' - Raison: {entry.reason}'
+ break
+ except:
+ pass
+
+ embed = discord.Embed(
+ title='👋 Membre parti',
+ description=leave_message,
+ color=discord.Color.red()
+ )
+
+ embed.set_thumbnail(url=member.display_avatar.url)
+ embed.add_field(name='Membre', value=f'{member.mention} ({member.name})', inline=True)
+ embed.add_field(name='Nombre de membres', value=str(member.guild.member_count), inline=True)
+ embed.add_field(name='Temps sur le serveur', value=duration_text, inline=False)
+ embed.set_footer(text=f'ID: {member.id}')
+
+ try:
+ await channel.send(embed=embed)
+ logging.info(f'Message de départ envoyé pour {member.name}')
+ except Exception as e:
+ logging.error(f'Échec de l\'envoi du message de départ : {e}')
+
diff --git a/docker-compose.yml b/docker-compose.yml
index ecea7ab..6d0afd3 100755
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,11 +1,11 @@
services:
mamiehenriette:
container_name: MamieHenriette # Nom du conteneur
- image: ghcr.io/skylanix/mamiehenriette:latest # Image hébergée sur GitHub Container Registry
restart: unless-stopped # Redémarre automatiquement sauf si arrêté manuellement
# build: . # Build du conteneur à partir d'un Dockerfile local (décommentez si nécessaire)
# image: mamiehenriette # Build du conteneur à partir d'un Dockerfile local (décommentez si nécessaire)
+ image: ghcr.io/skylanix/mamiehenriette:latest # Image hébergée sur GitHub Container Registry (commentez si nécessaire)
environment:
TZ: Europe/Paris # Fuseau horaire
@@ -45,4 +45,4 @@ services:
# volumes:
# - ./instance/database.db:/data/database.db # Monte la base de données locale dans le conteneur
# environment:
-# - SQLITE_DATABASE=/data/database.db # Chemin vers la base de données dans le conteneur
+# - SQLITE_DATABASE=/data/database.db # Chemin vers la base de données dans le conteneur
\ No newline at end of file
diff --git a/protondb/__init__.py b/protondb/__init__.py
index e2842bd..fd20f11 100644
--- a/protondb/__init__.py
+++ b/protondb/__init__.py
@@ -1,12 +1,14 @@
-
import logging
import requests
import re
+import json
+from datetime import datetime, timedelta
from algoliasearch.search.client import SearchClientSync, SearchConfig
+from database import db
from database.helpers import ConfigurationHelper
-from database.models import GameAlias
-from sqlalchemy import desc,func
+from database.models import GameAlias, AntiCheatCache, Configuration
+from sqlalchemy import desc, func
def _call_algoliasearch(search_name:str):
config = SearchConfig(ConfigurationHelper().getValue('proton_db_api_id'),
@@ -37,9 +39,130 @@ def _apply_game_aliases(search_name:str) -> str:
search_name = re.sub(re.escape(alias.alias), alias.name, search_name, flags=re.IGNORECASE)
return search_name
+def _should_update_anticheat_cache() -> bool:
+ try:
+ last_update_conf = Configuration.query.filter_by(key='anticheat_last_update').first()
+ if not last_update_conf:
+ return True
+ try:
+ last_update = datetime.fromisoformat(last_update_conf.value)
+ return datetime.now() - last_update > timedelta(days=7)
+ except:
+ return True
+ except Exception as e:
+ logging.error(f'Erreur lors de la vérification du cache anti-cheat: {e}')
+ return False
+
+def _fetch_anticheat_data():
+ try:
+ url = 'https://raw.githubusercontent.com/AreWeAntiCheatYet/AreWeAntiCheatYet/master/games.json'
+ response = requests.get(url, timeout=10)
+ if response.status_code == 200:
+ return response.json()
+ else:
+ logging.error(f'Échec de la récupération des données anti-cheat. Code HTTP: {response.status_code}')
+ return None
+ except Exception as e:
+ logging.error(f'Erreur lors de la récupération des données anti-cheat: {e}')
+ return None
+
+def _update_anticheat_cache_if_needed():
+ try:
+ if not _should_update_anticheat_cache():
+ return
+
+ logging.info('Mise à jour du cache anti-cheat...')
+ anticheat_data = _fetch_anticheat_data()
+ if not anticheat_data:
+ return
+
+ for game in anticheat_data:
+ try:
+ steam_id = str(game.get('storeIds', {}).get('steam', ''))
+ if not steam_id or steam_id == '0':
+ continue
+
+ cache_entry = AntiCheatCache.query.filter_by(steam_id=steam_id).first()
+
+ status = game.get('status', 'Unknown')
+ anticheats_list = game.get('anticheats', [])
+ anticheats_str = json.dumps(anticheats_list) if anticheats_list else None
+ reference = game.get('reference', '')
+ notes_data = game.get('notes', '')
+ if isinstance(notes_data, list):
+ notes = json.dumps(notes_data)
+ else:
+ notes = str(notes_data) if notes_data else ''
+ game_name = game.get('name', '')
+
+ if cache_entry:
+ cache_entry.game_name = game_name
+ cache_entry.status = status
+ cache_entry.anticheats = anticheats_str
+ cache_entry.reference = reference
+ cache_entry.notes = notes
+ cache_entry.updated_at = datetime.now()
+ else:
+ cache_entry = AntiCheatCache(
+ steam_id=steam_id,
+ game_name=game_name,
+ status=status,
+ anticheats=anticheats_str,
+ reference=reference,
+ notes=notes,
+ updated_at=datetime.now()
+ )
+ db.session.add(cache_entry)
+ except Exception as e:
+ logging.error(f'Erreur lors de la mise à jour du jeu {game.get("name")}: {e}')
+ continue
+
+ last_update_conf = Configuration.query.filter_by(key='anticheat_last_update').first()
+ if last_update_conf:
+ last_update_conf.value = datetime.now().isoformat()
+ else:
+ last_update_conf = Configuration(key='anticheat_last_update', value=datetime.now().isoformat())
+ db.session.add(last_update_conf)
+
+ db.session.commit()
+ logging.info('Cache anti-cheat mis à jour avec succès')
+ except Exception as e:
+ try:
+ db.session.rollback()
+ except:
+ pass
+ logging.error(f'Erreur lors de la mise à jour du cache anti-cheat: {e}')
+
+def _get_anticheat_info(steam_id: str) -> dict:
+ try:
+ cache_entry = AntiCheatCache.query.filter_by(steam_id=steam_id).first()
+ if not cache_entry:
+ return None
+
+ try:
+ anticheats = json.loads(cache_entry.anticheats) if cache_entry.anticheats else []
+ except:
+ anticheats = []
+
+ return {
+ 'status': cache_entry.status,
+ 'anticheats': anticheats,
+ 'reference': cache_entry.reference,
+ 'notes': cache_entry.notes
+ }
+ except Exception as e:
+ logging.error(f'Erreur lors de la récupération des infos anti-cheat pour {steam_id}: {e}')
+ return None
+
def searhProtonDb(search_name:str):
results = []
search_name = _apply_game_aliases(search_name)
+
+ try:
+ _update_anticheat_cache_if_needed()
+ except Exception as e:
+ logging.error(f'Erreur lors de la mise à jour du cache anti-cheat: {e}')
+
responses = _call_algoliasearch(search_name)
for hit in responses.model_dump().get('hits'):
id = hit.get('object_id')
@@ -49,12 +172,27 @@ def searhProtonDb(search_name:str):
summmary = _call_summary(id)
if (summmary != None) :
tier = summmary.get('tier')
- results.append({
+
+ anticheat_info = None
+ try:
+ anticheat_info = _get_anticheat_info(str(id))
+ except Exception as e:
+ logging.error(f'Erreur lors de la récupération anti-cheat pour {name}: {e}')
+
+ result = {
'id':id,
'name' : name,
'tier' : tier
- })
- logging.info(f'Trouvé {name}({id}) : {tier}')
+ }
+
+ if anticheat_info:
+ result['anticheat_status'] = anticheat_info.get('status')
+ result['anticheats'] = anticheat_info.get('anticheats', [])
+ result['anticheat_reference'] = anticheat_info.get('reference')
+ result['anticheat_notes'] = anticheat_info.get('notes')
+
+ results.append(result)
+ logging.info(f'Trouvé {name}({id}) : {tier}' + (f' [Anti-cheat: {anticheat_info.get("status")}]' if anticheat_info else ''))
except Exception as e:
logging.error(f'Erreur lors du traitement du jeu {name} (ID: {id}) : {e}')
else:
diff --git a/run-web.py b/run-web.py
index b865860..b37d3f9 100644
--- a/run-web.py
+++ b/run-web.py
@@ -1,6 +1,8 @@
import locale
import logging
import threading
+import os
+from logging.handlers import RotatingFileHandler
from webapp import webapp
from discordbot import bot
@@ -23,12 +25,40 @@ def start_twitch_bot():
twitchBot.begin()
if __name__ == '__main__':
+ # Config logs (console + fichier avec rotation)
+ os.makedirs('logs', exist_ok=True)
+ log_formatter = logging.Formatter('%(asctime)s %(levelname)s [%(threadName)s] %(name)s: %(message)s')
+ handlers = []
+ stream_handler = logging.StreamHandler()
+ stream_handler.setFormatter(log_formatter)
+ handlers.append(stream_handler)
+ file_handler = RotatingFileHandler('logs/app.log', maxBytes=5*1024*1024, backupCount=5, encoding='utf-8')
+ file_handler.setFormatter(log_formatter)
+ handlers.append(file_handler)
+ logging.basicConfig(level=logging.INFO, handlers=handlers)
+
+ # Calmer les logs verbeux de certaines libs si besoin
+ logging.getLogger('werkzeug').setLevel(logging.WARNING)
+ logging.getLogger('discord').setLevel(logging.WARNING)
+
+ # Hook exceptions non-capturées (threads inclus)
+ def _log_uncaught(exc_type, exc, tb):
+ logging.exception('Exception non capturée', exc_info=(exc_type, exc, tb))
+ import sys
+ sys.excepthook = _log_uncaught
+ if hasattr(threading, 'excepthook'):
+ def _thread_excepthook(args):
+ logging.exception(f"Exception dans le thread {args.thread.name}", exc_info=(args.exc_type, args.exc_value, args.exc_traceback))
+ threading.excepthook = _thread_excepthook
+
locale.setlocale(locale.LC_TIME, 'fr_FR.UTF-8')
jobs = []
- jobs.append(threading.Thread(target=start_discord_bot))
- jobs.append(threading.Thread(target=start_server))
- jobs.append(threading.Thread(target=start_twitch_bot))
+ jobs.append(threading.Thread(target=start_discord_bot, name='discord-bot'))
+ jobs.append(threading.Thread(target=start_server, name='web-server'))
+ jobs.append(threading.Thread(target=start_twitch_bot, name='twitch-bot'))
- for job in jobs: job.start()
- for job in jobs: job.join()
+ for job in jobs:
+ job.start()
+ for job in jobs:
+ job.join()
diff --git a/webapp/__init__.py b/webapp/__init__.py
index 357676a..28f6c3c 100644
--- a/webapp/__init__.py
+++ b/webapp/__init__.py
@@ -2,4 +2,4 @@ from flask import Flask
webapp = Flask(__name__)
-from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth
+from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation
diff --git a/webapp/configurations.py b/webapp/configurations.py
index 93d5d0b..a247570 100644
--- a/webapp/configurations.py
+++ b/webapp/configurations.py
@@ -6,17 +6,37 @@ from discordbot import bot
@webapp.route("/configurations")
def openConfigurations():
- return render_template("configurations.html", configuration = ConfigurationHelper(), channels = bot.getAllTextChannel())
+ return render_template("configurations.html", configuration = ConfigurationHelper(), channels = bot.getAllTextChannel(), roles = bot.getAllRoles())
@webapp.route("/configurations/update", methods=['POST'])
def updateConfiguration():
- for key in request.form :
- ConfigurationHelper().createOrUpdate(key, request.form.get(key))
- # Je fais ça car HTML n'envoie pas le paramètre de checkbox quand il est décoché
- if (request.form.get("humble_bundle_channel") != None and request.form.get("humble_bundle_enable") == None) :
- ConfigurationHelper().createOrUpdate('humble_bundle_enable', False)
- if (request.form.get("proton_db_api_id") != None and request.form.get("proton_db_enable_enable") == None) :
- ConfigurationHelper().createOrUpdate('proton_db_enable_enable', False)
+ checkboxes = {
+ 'humble_bundle_enable': 'humble_bundle_channel',
+ 'proton_db_enable_enable': 'proton_db_api_id',
+ 'moderation_enable': 'moderation_staff_role_ids',
+ 'moderation_ban_enable': 'moderation_staff_role_ids',
+ 'moderation_kick_enable': 'moderation_staff_role_ids',
+ 'welcome_enable': 'welcome_channel_id',
+ 'leave_enable': 'leave_channel_id'
+ }
+
+ staff_roles = request.form.getlist('moderation_staff_role_ids')
+ if staff_roles:
+ ConfigurationHelper().createOrUpdate('moderation_staff_role_ids', ','.join(staff_roles))
+ else:
+ ConfigurationHelper().createOrUpdate('moderation_staff_role_ids', '')
+
+ for key in request.form:
+ if key == 'moderation_staff_role_ids':
+ continue
+ value = request.form.get(key)
+ if value and value.strip():
+ ConfigurationHelper().createOrUpdate(key, value)
+
+ for checkbox, reference_field in checkboxes.items():
+ if request.form.get(reference_field) is not None and request.form.get(checkbox) is None:
+ ConfigurationHelper().createOrUpdate(checkbox, False)
+
db.session.commit()
return redirect(request.referrer)
diff --git a/webapp/moderation.py b/webapp/moderation.py
new file mode 100644
index 0000000..5571487
--- /dev/null
+++ b/webapp/moderation.py
@@ -0,0 +1,30 @@
+from flask import render_template, request, redirect, url_for
+from webapp import webapp
+from database import db
+from database.models import ModerationEvent
+
+@webapp.route("/moderation")
+def moderation():
+ events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
+ return render_template("moderation.html", events=events, event=None)
+
+@webapp.route("/moderation/edit/")
+def open_edit_moderation_event(event_id):
+ event = ModerationEvent.query.get_or_404(event_id)
+ events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
+ return render_template("moderation.html", events=events, event=event)
+
+@webapp.route("/moderation/update/", methods=['POST'])
+def update_moderation_event(event_id):
+ event = ModerationEvent.query.get_or_404(event_id)
+ event.reason = request.form.get('reason')
+ db.session.commit()
+ return redirect(url_for('moderation'))
+
+@webapp.route("/moderation/delete/")
+def delete_moderation_event(event_id):
+ event = ModerationEvent.query.get_or_404(event_id)
+ db.session.delete(event)
+ db.session.commit()
+ return redirect(url_for('moderation'))
+
diff --git a/webapp/templates/configurations.html b/webapp/templates/configurations.html
index c8c43f3..40cf0bf 100644
--- a/webapp/templates/configurations.html
+++ b/webapp/templates/configurations.html
@@ -2,14 +2,192 @@
{% block content %}
Configuration de Mamie
-Configurez les tokens Discord, les notifications Humble Bundle et l'API ProtonDB pour la commande !protondb.
+Configurez les tokens Discord, les notifications Humble Bundle et l'API Twitch.
-API Discord
+Discord
API Twitch
@@ -21,7 +199,7 @@
Chaîne à rejoindre
-
+
Aide
@@ -41,18 +219,22 @@
Humble Bundle
- Activer
-
- Activer les notifications Humble Bundle
- Canal de notification des packs Humble Bundle
+ Humble Bundle propose régulièrement des bundles de jeux vidéo à des prix réduits. Activez les notifications pour recevoir automatiquement les nouveaux packs disponibles sur votre serveur Discord.
+
+
+
+ Activer les notifications Humble Bundle
+
+
+ Canal de notification
{% for channel in channels %}
-
- {{channel.name}}
+
+ {{channel.name}}
+
{% endfor %}
-
+
+
{% endblock %}
\ No newline at end of file
diff --git a/webapp/templates/moderation.html b/webapp/templates/moderation.html
new file mode 100644
index 0000000..fc1e316
--- /dev/null
+++ b/webapp/templates/moderation.html
@@ -0,0 +1,110 @@
+{% extends "template.html" %}
+
+{% block content %}
+Modération Discord
+
+
+ Historique des actions de modération effectuées sur le serveur Discord.
+
+ Le bot enregistre automatiquement les avertissements, exclusions et bannissements.
+
+
+
+
+ Commande
+ Description
+
+
+
+
+ !averto @utilisateur raison Alias : !warn, !av, !avertissement
+ Avertit un utilisateur et enregistre l'avertissement dans la base de données
+
+
+ !delaverto id Alias : !removewarn, !delwarn
+ Retire un avertissement en utilisant son numéro d'ID
+
+
+ !warnings ou !warnings @utilisateur Alias : !listevent, !listwarn
+ Affiche la liste des événements de modération (tous ou pour un utilisateur spécifique)
+
+
+ !inspect @utilisateur ou !inspect id
+ Affiche des informations détaillées sur un utilisateur : création du compte, date d'arrivée, historique de modération
+
+
+ !kick @utilisateur raison
+ Expulse un utilisateur du serveur
+
+
+ !ban @utilisateur raison
+ Bannit définitivement un utilisateur du serveur
+
+
+ !unban discord_id ou !unban #sanction_id raison
+ Révoque le bannissement d'un utilisateur et lui envoie une invitation
+
+
+ !banlist
+ Affiche la liste des utilisateurs actuellement bannis du serveur
+
+
+ !aide Alias : !help
+ Affiche l'aide avec toutes les commandes disponibles
+
+
+
+
+
+{% if not event %}
+Événements de modération
+
+
+
+ Type
+ Utilisateur
+ Discord ID
+ Date & Heure
+ Raison
+ Staff
+ #
+
+
+
+ {% for mod_event in events %}
+
+ {{ mod_event.type }}
+ {{ mod_event.username }}
+ {{ mod_event.discord_id }}
+ {{ mod_event.created_at.strftime('%d/%m/%Y %H:%M') if mod_event.created_at else 'N/A' }}
+ {{ mod_event.reason }}
+ {{ mod_event.staff_name }}
+
+ ✐
+ 🗑
+
+
+ {% endfor %}
+
+
+{% endif %}
+
+{% if event %}
+Editer un événement
+
+ Type
+
+ Utilisateur
+
+ Discord ID
+
+ Raison
+
+ Staff
+
+
+ Annuler
+
+{% endif %}
+
+{% endblock %}
diff --git a/webapp/templates/template.html b/webapp/templates/template.html
index c1b26b6..e36f520 100644
--- a/webapp/templates/template.html
+++ b/webapp/templates/template.html
@@ -21,6 +21,7 @@
Alerte live
Commandes
Humeurs
+ Modération
ProtonDB
Configurations