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

- - - -

Nécessite un redémarrage

+
+ API Discord + + + Nécessite un redémarrage après modification +
+ +
+ Messages de bienvenue + + + + + + + + + Syntaxes disponibles :
+ • {member.mention} - Mentionne l'utilisateur (@NomUtilisateur)
+ • {member.name} - Nom d'utilisateur (sans mention)
+ • {member.display_name} - Surnom sur le serveur
+ • {member.id} - ID de l'utilisateur
+ • {server.name} - Nom du serveur
+ • {server.member_count} - Nombre total de membres
+ • <#ID_DU_CHANNEL> - Mentionne un salon (ex: <#123456789012345678>) +
+
+ +
+ Messages de départ + + + + + + + + + Syntaxes disponibles :
+ • {member.mention} - Mentionne l'utilisateur (@NomUtilisateur)
+ • {member.name} - Nom d'utilisateur (sans mention)
+ • {member.display_name} - Surnom sur le serveur
+ • {member.id} - ID de l'utilisateur
+ • {server.name} - Nom du serveur
+ • {server.member_count} - Nombre total de membres
+ • <#ID_DU_CHANNEL> - Mentionne un salon (ex: <#123456789012345678>) +
+
+ +
+ Modération + + + + + + + + + Toutes les actions de modération seront notifiées dans ce canal + + + {% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %} + + {% if roles|length > 1 %} +
+ {% for guild_data in roles %} + + {% endfor %} +
+ {% endif %} + + {% for guild_data in roles %} +
+
+ {% for role in guild_data.roles %} + + {% endfor %} +
+
+ {% endfor %} + + Sélectionnez un ou plusieurs rôles qui peuvent utiliser les commandes de modération + + + + + + + + Mettre 0 pour ne pas supprimer automatiquement +
+ +

API Twitch

@@ -21,7 +199,7 @@ - +

Aide

@@ -41,18 +219,22 @@

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.

+ + + + - + +
{% 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. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CommandeDescription
!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 idAffiche des informations détaillées sur un utilisateur : création du compte, date d'arrivée, historique de modération
!kick @utilisateur raisonExpulse un utilisateur du serveur
!ban @utilisateur raisonBannit définitivement un utilisateur du serveur
!unban discord_id ou !unban #sanction_id raisonRévoque le bannissement d'un utilisateur et lui envoie une invitation
!banlistAffiche 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

+ + + + + + + + + + + + + + {% for mod_event in events %} + + + + + + + + + + {% endfor %} + +
TypeUtilisateurDiscord IDDate & HeureRaisonStaff#
{{ 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 }} + + 🗑 +
+{% endif %} + +{% if event %} +

Editer un événement

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