Merge pull request #36 from skylanix Fonctionnalités de modération

Fonctionnalités de modération
This commit is contained in:
skylanix
2025-11-12 01:26:15 +00:00
committed by GitHub
17 changed files with 2550 additions and 71 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@
__pycache__ __pycache__
instance instance
.tio.tokens.json .tio.tokens.json
**/logs

192
README.md
View File

@@ -14,6 +14,8 @@
- [Prérequis](#prérequis) - [Prérequis](#prérequis)
- [Création du bot Discord](#création-du-bot-discord) - [Création du bot Discord](#création-du-bot-discord)
- [Démarrage rapide](#démarrage-rapide) - [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) - [Volumes persistants](#volumes-persistants)
- [Commandes Docker utiles](#commandes-docker-utiles) - [Commandes Docker utiles](#commandes-docker-utiles)
- [Mise à jour](#mise-à-jour) - [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) - **Statuts dynamiques** : Rotation automatique des humeurs (10 min)
- **Notifications Humble Bundle** : Surveillance et alertes automatiques (30 min) - **Notifications Humble Bundle** : Surveillance et alertes automatiques (30 min)
- **Commandes personnalisées** : Gestion via interface web - **Commandes personnalisées** : Gestion via interface web
- **Recherche ProtonDB** : Commande `!protondb <nom_du_jeu>` pour vérifier la compatibilité Linux/Steam Deck - **Recherche ProtonDB** :
- **Modération** : Outils intégrés - 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 ### Twitch
- **Chat bot** : Commandes et interactions automatiques - **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 - Support jusqu'à 100 chaînes simultanément
- Notifications Discord avec aperçu du stream - Notifications Discord avec aperçu du stream
- Gestion via interface d'administration - 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 ### Interface d'administration
- **Dashboard** : Vue d'ensemble et statistiques - **Dashboard** : Vue d'ensemble et statistiques
- **Configuration** : Tokens, paramètres des plateformes, configuration ProtonDB - **Configuration** :
- **Gestion des humeurs** : Création et modification des statuts - Tokens Discord/Twitch et paramètres des plateformes
- **Commandes** : Édition des commandes personnalisées - Configuration ProtonDB (API Algolia)
- **Modération** : Outils de gestion communautaire - 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 ## Installation
@@ -115,22 +167,123 @@ Avant d'installer MamieHenriette, vous devez créer un bot Discord et obtenir so
```bash ```bash
# 1. Cloner le projet # 1. Cloner le projet
git clone https://github.com/skylanix/MamieHenriette.git git clone https://github.com/skylanix/MamieHenriette.git
```
```bash
cd MamieHenriette cd MamieHenriette
``` ```
```bash ```bash
# 2. Lancer avec Docker # 2. Récupérer l'image depuis GitHub Container Registry et lancer
docker compose up --build -d 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 > ```bash
> docker compose restart MamieHenriette > 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 ### Volumes persistants
- `./instance/` : Base de données SQLite et configuration - `./instance/` : Base de données SQLite et configuration
- `./logs/` : Logs applicatifs rotatifs (50MB max par fichier) - `./logs/` : Logs applicatifs rotatifs (50MB max par fichier)
@@ -165,10 +318,12 @@ git pull origin main
# 3. Mettre à jour l'image Docker # 3. Mettre à jour l'image Docker
docker compose pull docker compose pull
# 4. Reconstruire et relancer # 4. Relancer
docker compose up --build -d 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) #### Sans Docker (installation locale)
```bash ```bash
# 1. Arrêter l'application # 1. Arrêter l'application
@@ -236,13 +391,16 @@ python run-web.py
## Spécifications techniques ## Spécifications techniques
### Base de données (SQLite) ### 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 - **Humeur** : Statuts Discord rotatifs avec gestion automatique
- **Commande** : Commandes personnalisées multi-plateformes (Discord/Twitch) - **Commande** : Commandes personnalisées multi-plateformes (Discord/Twitch)
- **LiveAlert** : Configuration surveillance streamers Twitch (nom, canal Discord, statut) - **LiveAlert** : Configuration surveillance streamers Twitch (nom, canal Discord, statut)
- **GameAlias** : Alias pour améliorer les recherches ProtonDB - **GameAlias** : Alias pour améliorer les recherches ProtonDB
- **GameBundle** : Historique et notifications Humble Bundle - **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 ### Architecture multi-thread
- **Thread 1** : Interface web Flask (port 5000) avec logging rotatif - **Thread 1** : Interface web Flask (port 5000) avec logging rotatif

View File

@@ -1,6 +1,8 @@
import logging import logging
import json import json
import os import os
from sqlalchemy import event
from sqlalchemy.engine import Engine
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from sqlite3 import Cursor, Connection from sqlite3 import Cursor, Connection
@@ -9,8 +11,28 @@ from webapp import webapp
basedir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) 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")}' 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) 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: def _tableHaveColumn(table_name:str, column_name:str, cursor:Cursor) -> bool:
cursor.execute(f'PRAGMA table_info({table_name})') cursor.execute(f'PRAGMA table_info({table_name})')
columns = cursor.fetchall() columns = cursor.fetchall()

View File

@@ -40,3 +40,24 @@ class Commande(db.Model):
trigger = db.Column(db.String(32), unique=True) trigger = db.Column(db.String(32), unique=True)
response = db.Column(db.String(2000)) 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)

View File

@@ -45,3 +45,34 @@ CREATE TABLE IF NOT EXISTS `commande` (
`trigger` VARCHAR(16) UNIQUE NOT NULL, `trigger` VARCHAR(16) UNIQUE NOT NULL,
`response` VARCHAR(2000) 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
);

View File

@@ -6,8 +6,22 @@ import random
from database import db from database import db
from database.helpers import ConfigurationHelper from database.helpers import ConfigurationHelper
from database.models import Configuration, Humeur, Commande 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.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 from protondb import searhProtonDb
class DiscordBot(discord.Client): class DiscordBot(discord.Client):
@@ -16,6 +30,9 @@ class DiscordBot(discord.Client):
for c in self.get_all_channels() : for c in self.get_all_channels() :
logging.info(f'{c.id} {c.name}') 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.updateStatus())
self.loop.create_task(self.updateHumbleBundle()) self.loop.create_task(self.updateHumbleBundle())
@@ -42,17 +59,34 @@ class DiscordBot(discord.Client):
if isinstance(channel, TextChannel): if isinstance(channel, TextChannel):
channels.append(channel) channels.append(channel)
return channels 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) : def begin(self) :
token = Configuration.query.filter_by(key='discord_token').first() token = Configuration.query.filter_by(key='discord_token').first()
if token : if token and token.value and token.value.strip():
self.run(token.value) self.run(token.value)
else : else :
logging.error('Aucun token Discord configuré. Le bot ne peut pas être démarré') logging.error('Aucun token Discord configuré. Le bot ne peut pas être démarré')
intents = discord.Intents.default() intents = discord.Intents.default()
intents.message_content = True intents.message_content = True
intents.members = True
intents.invites = True
bot = DiscordBot(intents=intents) bot = DiscordBot(intents=intents)
# https://discordpy.readthedocs.io/en/stable/quickstart.html # https://discordpy.readthedocs.io/en/stable/quickstart.html
@@ -63,6 +97,54 @@ async def on_message(message: Message):
if not message.content.startswith('!'): if not message.content.startswith('!'):
return return
command_name = message.content.split()[0] 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() commande = Commande.query.filter_by(discord_enable=True, trigger=command_name).first()
if commande: if commande:
try: try:
@@ -71,25 +153,94 @@ async def on_message(message: Message):
except Exception as e: except Exception as e:
logging.error(f'Échec de l\'exécution de la commande Discord : {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) : if (message.content.find('<@')>0) :
mention = message.content[message.content.find('<@'):] mention = message.content[message.content.find('<@'):]
else : else :
mention = message.author.mention 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) games = searhProtonDb(name)
if (len(games)==0) : 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 ?' msg = f'{mention} Je n\'ai pas trouvé de jeux correspondant à **{name}**. Es-tu sûr que le jeu est disponible sur Steam ?'
else : try:
msg = f'{mention} J\'ai trouvé {len(games)} jeux :\n' await message.channel.send(msg, suppress_embeds=True)
ite = iter(games) except Exception as e:
while (game := next(ite, None)) is not None and len(msg) < 1850 : logging.error(f"Échec de l'envoi du message ProtonDB : {e}")
msg += f'- [{game.get('name')}](https://www.protondb.com/app/{game.get('id')}) classé **{game.get('tier')}**\n' return
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}')
# 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)

1392
discordbot/moderation.py Normal file

File diff suppressed because it is too large Load Diff

192
discordbot/welcome.py Normal file
View File

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

View File

@@ -1,11 +1,11 @@
services: services:
mamiehenriette: mamiehenriette:
container_name: MamieHenriette # Nom du conteneur 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 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) # 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: 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: environment:
TZ: Europe/Paris # Fuseau horaire TZ: Europe/Paris # Fuseau horaire
@@ -45,4 +45,4 @@ services:
# volumes: # volumes:
# - ./instance/database.db:/data/database.db # Monte la base de données locale dans le conteneur # - ./instance/database.db:/data/database.db # Monte la base de données locale dans le conteneur
# environment: # 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

View File

@@ -1,12 +1,14 @@
import logging import logging
import requests import requests
import re import re
import json
from datetime import datetime, timedelta
from algoliasearch.search.client import SearchClientSync, SearchConfig from algoliasearch.search.client import SearchClientSync, SearchConfig
from database import db
from database.helpers import ConfigurationHelper from database.helpers import ConfigurationHelper
from database.models import GameAlias from database.models import GameAlias, AntiCheatCache, Configuration
from sqlalchemy import desc,func from sqlalchemy import desc, func
def _call_algoliasearch(search_name:str): def _call_algoliasearch(search_name:str):
config = SearchConfig(ConfigurationHelper().getValue('proton_db_api_id'), 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) search_name = re.sub(re.escape(alias.alias), alias.name, search_name, flags=re.IGNORECASE)
return search_name 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): def searhProtonDb(search_name:str):
results = [] results = []
search_name = _apply_game_aliases(search_name) 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) responses = _call_algoliasearch(search_name)
for hit in responses.model_dump().get('hits'): for hit in responses.model_dump().get('hits'):
id = hit.get('object_id') id = hit.get('object_id')
@@ -49,12 +172,27 @@ def searhProtonDb(search_name:str):
summmary = _call_summary(id) summmary = _call_summary(id)
if (summmary != None) : if (summmary != None) :
tier = summmary.get('tier') 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, 'id':id,
'name' : name, 'name' : name,
'tier' : tier '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: except Exception as e:
logging.error(f'Erreur lors du traitement du jeu {name} (ID: {id}) : {e}') logging.error(f'Erreur lors du traitement du jeu {name} (ID: {id}) : {e}')
else: else:

View File

@@ -1,6 +1,8 @@
import locale import locale
import logging import logging
import threading import threading
import os
from logging.handlers import RotatingFileHandler
from webapp import webapp from webapp import webapp
from discordbot import bot from discordbot import bot
@@ -23,12 +25,40 @@ def start_twitch_bot():
twitchBot.begin() twitchBot.begin()
if __name__ == '__main__': 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') locale.setlocale(locale.LC_TIME, 'fr_FR.UTF-8')
jobs = [] jobs = []
jobs.append(threading.Thread(target=start_discord_bot)) jobs.append(threading.Thread(target=start_discord_bot, name='discord-bot'))
jobs.append(threading.Thread(target=start_server)) jobs.append(threading.Thread(target=start_server, name='web-server'))
jobs.append(threading.Thread(target=start_twitch_bot)) jobs.append(threading.Thread(target=start_twitch_bot, name='twitch-bot'))
for job in jobs: job.start() for job in jobs:
for job in jobs: job.join() job.start()
for job in jobs:
job.join()

View File

@@ -2,4 +2,4 @@ from flask import Flask
webapp = Flask(__name__) 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

View File

@@ -6,17 +6,37 @@ from discordbot import bot
@webapp.route("/configurations") @webapp.route("/configurations")
def openConfigurations(): 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']) @webapp.route("/configurations/update", methods=['POST'])
def updateConfiguration(): def updateConfiguration():
for key in request.form : checkboxes = {
ConfigurationHelper().createOrUpdate(key, request.form.get(key)) 'humble_bundle_enable': 'humble_bundle_channel',
# Je fais ça car HTML n'envoie pas le paramètre de checkbox quand il est décoché 'proton_db_enable_enable': 'proton_db_api_id',
if (request.form.get("humble_bundle_channel") != None and request.form.get("humble_bundle_enable") == None) : 'moderation_enable': 'moderation_staff_role_ids',
ConfigurationHelper().createOrUpdate('humble_bundle_enable', False) 'moderation_ban_enable': 'moderation_staff_role_ids',
if (request.form.get("proton_db_api_id") != None and request.form.get("proton_db_enable_enable") == None) : 'moderation_kick_enable': 'moderation_staff_role_ids',
ConfigurationHelper().createOrUpdate('proton_db_enable_enable', False) '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() db.session.commit()
return redirect(request.referrer) return redirect(request.referrer)

30
webapp/moderation.py Normal file
View File

@@ -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/<int:event_id>")
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/<int:event_id>", 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/<int:event_id>")
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'))

View File

@@ -2,14 +2,192 @@
{% block content %} {% block content %}
<h1>Configuration de Mamie</h1> <h1>Configuration de Mamie</h1>
<p>Configurez les tokens Discord, les notifications Humble Bundle et l'API ProtonDB pour la commande !protondb.</p> <p>Configurez les tokens Discord, les notifications Humble Bundle et l'API Twitch.</p>
<h2>API Discord</h2> <h2>Discord</h2>
<form action="{{ url_for('updateConfiguration') }}" method="POST"> <form action="{{ url_for('updateConfiguration') }}" method="POST">
<label for="discord_token">API Discord (cachée)</label> <fieldset>
<input name="discord_token" type="password" /> <legend>API Discord</legend>
<input type="Submit" value="Définir"> <label for="discord_token">Token Discord (caché)</label>
<p>Nécessite un redémarrage</p> <input name="discord_token" type="password" placeholder="Votre token Discord" />
<small>Nécessite un redémarrage après modification</small>
</fieldset>
<fieldset>
<legend>Messages de bienvenue</legend>
<label for="welcome_enable">
<input type="checkbox" name="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked="checked"{% endif %}>
Activer le message de bienvenue pour les nouveaux membres
</label>
<label for="welcome_channel_id">Canal de bienvenue</label>
<select name="welcome_channel_id">
{% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('welcome_channel_id')==channel.id %}selected="selected"{% endif %}>
{{channel.name}}
</option>
{% endfor %}
</select>
<label for="welcome_message">Message personnalisé de bienvenue</label>
<textarea name="welcome_message" rows="3" placeholder="Bienvenue {member.mention} sur le serveur !">{{ configuration.getValue('welcome_message') }}</textarea>
<small>
<strong>Syntaxes disponibles :</strong><br>
<code>{member.mention}</code> - Mentionne l'utilisateur (@NomUtilisateur)<br>
<code>{member.name}</code> - Nom d'utilisateur (sans mention)<br>
<code>{member.display_name}</code> - Surnom sur le serveur<br>
<code>{member.id}</code> - ID de l'utilisateur<br>
<code>{server.name}</code> - Nom du serveur<br>
<code>{server.member_count}</code> - Nombre total de membres<br>
<code>&lt;#ID_DU_CHANNEL&gt;</code> - Mentionne un salon (ex: &lt;#123456789012345678&gt;)
</small>
</fieldset>
<fieldset>
<legend>Messages de départ</legend>
<label for="leave_enable">
<input type="checkbox" name="leave_enable" {% if configuration.getValue('leave_enable') %}checked="checked"{% endif %}>
Activer le message de départ quand un membre quitte le serveur
</label>
<label for="leave_channel_id">Canal de départ</label>
<select name="leave_channel_id">
{% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('leave_channel_id')==channel.id %}selected="selected"{% endif %}>
{{channel.name}}
</option>
{% endfor %}
</select>
<label for="leave_message">Message personnalisé de départ</label>
<textarea name="leave_message" rows="3" placeholder="{member.mention} a quitté le serveur.">{{ configuration.getValue('leave_message') }}</textarea>
<small>
<strong>Syntaxes disponibles :</strong><br>
<code>{member.mention}</code> - Mentionne l'utilisateur (@NomUtilisateur)<br>
<code>{member.name}</code> - Nom d'utilisateur (sans mention)<br>
<code>{member.display_name}</code> - Surnom sur le serveur<br>
<code>{member.id}</code> - ID de l'utilisateur<br>
<code>{server.name}</code> - Nom du serveur<br>
<code>{server.member_count}</code> - Nombre total de membres<br>
<code>&lt;#ID_DU_CHANNEL&gt;</code> - Mentionne un salon (ex: &lt;#123456789012345678&gt;)
</small>
</fieldset>
<fieldset>
<legend>Modération</legend>
<label for="moderation_enable">
<input type="checkbox" name="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked="checked"{% endif %}>
Activer les commandes d'avertissement (!warn, !unwarn, !inspect)
</label>
<label for="moderation_ban_enable">
<input type="checkbox" name="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked="checked"{% endif %}>
Activer les commandes de bannissement (!ban, !unban)
</label>
<label for="moderation_kick_enable">
<input type="checkbox" name="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked="checked"{% endif %}>
Activer la commande d'expulsion (!kick)
</label>
<label for="moderation_log_channel_id">Canal de logs de modération</label>
<select name="moderation_log_channel_id">
{% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('moderation_log_channel_id')==channel.id %}selected="selected"{% endif %}>
{{channel.name}}
</option>
{% endfor %}
</select>
<small>Toutes les actions de modération seront notifiées dans ce canal</small>
<label>Rôles Staff autorisés</label>
{% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %}
{% if roles|length > 1 %}
<div class="tabs">
{% for guild_data in roles %}
<button type="button" class="tab-button" onclick="openTab(event, 'guild-{{guild_data.guild_id}}')" {% if loop.first %}id="defaultOpen"{% endif %}>
{{ guild_data.guild_name }}
</button>
{% endfor %}
</div>
{% endif %}
{% for guild_data in roles %}
<div id="guild-{{guild_data.guild_id}}" class="tab-content" {% if not loop.first %}style="display: none;"{% endif %}>
<div style="max-height: 300px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; border-radius: 5px;">
{% for role in guild_data.roles %}
<label style="display: block; margin: 5px 0;">
<input type="checkbox" name="moderation_staff_role_ids" value="{{role.id}}" {% if role.id|string in selected_roles %}checked="checked"{% endif %}>
{% if role.color.value != 0 %}
<span style="color:#{{ '%06x' % role.color.value }}"></span>
{% else %}
<span></span>
{% endif %}
{{role.name}}
</label>
{% endfor %}
</div>
</div>
{% endfor %}
<small>Sélectionnez un ou plusieurs rôles qui peuvent utiliser les commandes de modération</small>
<script>
function openTab(evt, tabName) {
var i, tabcontent, tabbuttons;
tabcontent = document.getElementsByClassName("tab-content");
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].style.display = "none";
}
tabbuttons = document.getElementsByClassName("tab-button");
for (i = 0; i < tabbuttons.length; i++) {
tabbuttons[i].className = tabbuttons[i].className.replace(" active", "");
}
document.getElementById(tabName).style.display = "block";
evt.currentTarget.className += " active";
}
document.getElementById("defaultOpen")?.click();
</script>
<style>
.tabs {
overflow: hidden;
border-bottom: 2px solid #ccc;
margin-bottom: 10px;
}
.tab-button {
background-color: #f1f1f1;
border: none;
outline: none;
cursor: pointer;
padding: 10px 20px;
transition: 0.3s;
font-size: 14px;
margin-right: 2px;
}
.tab-button:hover {
background-color: #ddd;
}
.tab-button.active {
background-color: #ccc;
font-weight: bold;
}
.tab-content {
animation: fadeEffect 0.3s;
}
@keyframes fadeEffect {
from {opacity: 0;}
to {opacity: 1;}
}
</style>
<label for="moderation_embed_delete_delay">Délai de suppression des embeds (en secondes)</label>
<input name="moderation_embed_delete_delay" type="number" value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" placeholder="0" min="0" />
<small>Mettre 0 pour ne pas supprimer automatiquement</small>
</fieldset>
<input type="Submit" value="Enregistrer la configuration Discord">
</form> </form>
<h2>API Twitch</h2> <h2>API Twitch</h2>
@@ -21,7 +199,7 @@
<label for="twitch_channel">Chaîne à rejoindre</label> <label for="twitch_channel">Chaîne à rejoindre</label>
<input name="twitch_channel" type="text" value="{{ configuration.getValue('twitch_channel') }}" <input name="twitch_channel" type="text" value="{{ configuration.getValue('twitch_channel') }}"
placeholder="#machinTruc" /> placeholder="#machinTruc" />
<input type="Submit" value="Définir"> <input type="Submit" value="Enregistrer la configuration Twitch">
<p> <p>
<a href="{{ url_for('twitchConfigurationHelp') }}">Aide</a> <a href="{{ url_for('twitchConfigurationHelp') }}">Aide</a>
</p> </p>
@@ -41,18 +219,22 @@
<h2>Humble Bundle</h2> <h2>Humble Bundle</h2>
<form action="{{ url_for('updateConfiguration') }}" method="POST"> <form action="{{ url_for('updateConfiguration') }}" method="POST">
<label for="humble_bundle_enable">Activer</label> <p>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.</p>
<input type="checkbox" name="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}
checked="checked" {% endif %}> <label for="humble_bundle_enable">
<label>Activer les notifications Humble Bundle</label> <input type="checkbox" name="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked="checked"{% endif %}>
<label for="humble_bundle_channel">Canal de notification des packs Humble Bundle</label> Activer les notifications Humble Bundle
</label>
<label for="humble_bundle_channel">Canal de notification</label>
<select name="humble_bundle_channel"> <select name="humble_bundle_channel">
{% for channel in channels %} {% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('humble_bundle_channel')==channel.id %} <option value="{{channel.id}}" {% if configuration.getIntValue('humble_bundle_channel')==channel.id %}selected="selected"{% endif %}>
selected="selected" {% endif %}> {{channel.name}}
{{channel.name}}</option> </option>
{% endfor %} {% endfor %}
</select> </select>
<input type="Submit" value="Définir">
<input type="Submit" value="Enregistrer la configuration Humble Bundle">
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,110 @@
{% extends "template.html" %}
{% block content %}
<h1>Modération Discord</h1>
<p>
Historique des actions de modération effectuées sur le serveur Discord.
Le bot enregistre automatiquement les avertissements, exclusions et bannissements.
<table>
<thead>
<tr>
<th>Commande</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>!averto @utilisateur raison</strong><br><small>Alias : !warn, !av, !avertissement</small></td>
<td>Avertit un utilisateur et enregistre l'avertissement dans la base de données</td>
</tr>
<tr>
<td><strong>!delaverto id</strong><br><small>Alias : !removewarn, !delwarn</small></td>
<td>Retire un avertissement en utilisant son numéro d'ID</td>
</tr>
<tr>
<td><strong>!warnings</strong> ou <strong>!warnings @utilisateur</strong><br><small>Alias : !listevent, !listwarn</small></td>
<td>Affiche la liste des événements de modération (tous ou pour un utilisateur spécifique)</td>
</tr>
<tr>
<td><strong>!inspect @utilisateur</strong> ou <strong>!inspect id</strong></td>
<td>Affiche des informations détaillées sur un utilisateur : création du compte, date d'arrivée, historique de modération</td>
</tr>
<tr>
<td><strong>!kick @utilisateur raison</strong></td>
<td>Expulse un utilisateur du serveur</td>
</tr>
<tr>
<td><strong>!ban @utilisateur raison</strong></td>
<td>Bannit définitivement un utilisateur du serveur</td>
</tr>
<tr>
<td><strong>!unban discord_id</strong> ou <strong>!unban #sanction_id raison</strong></td>
<td>Révoque le bannissement d'un utilisateur et lui envoie une invitation</td>
</tr>
<tr>
<td><strong>!banlist</strong></td>
<td>Affiche la liste des utilisateurs actuellement bannis du serveur</td>
</tr>
<tr>
<td><strong>!aide</strong><br><small>Alias : !help</small></td>
<td>Affiche l'aide avec toutes les commandes disponibles</td>
</tr>
</tbody>
</table>
</p>
{% if not event %}
<h2>Événements de modération</h2>
<table class="moderation">
<thead>
<tr>
<th>Type</th>
<th>Utilisateur</th>
<th>Discord ID</th>
<th>Date & Heure</th>
<th>Raison</th>
<th>Staff</th>
<th>#</th>
</tr>
</thead>
<tbody>
{% for mod_event in events %}
<tr>
<td>{{ mod_event.type }}</td>
<td>{{ mod_event.username }}</td>
<td>{{ mod_event.discord_id }}</td>
<td>{{ mod_event.created_at.strftime('%d/%m/%Y %H:%M') if mod_event.created_at else 'N/A' }}</td>
<td>{{ mod_event.reason }}</td>
<td>{{ mod_event.staff_name }}</td>
<td>
<a href="{{ url_for('open_edit_moderation_event', event_id = mod_event.id) }}" class="icon"></a>
<a href="{{ url_for('delete_moderation_event', event_id = mod_event.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')" class="icon">🗑</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if event %}
<h2>Editer un événement</h2>
<form action="{{ url_for('update_moderation_event', event_id = event.id) }}" method="POST">
<label for="type">Type</label>
<input name="type" type="text" value="{{ event.type }}" disabled />
<label for="username">Utilisateur</label>
<input name="username" type="text" value="{{ event.username }}" disabled />
<label for="discord_id">Discord ID</label>
<input name="discord_id" type="text" value="{{ event.discord_id }}" disabled />
<label for="reason">Raison</label>
<input name="reason" type="text" value="{{ event.reason }}" required="required" />
<label for="staff_name">Staff</label>
<input name="staff_name" type="text" value="{{ event.staff_name }}" disabled />
<input type="Submit" value="Modifier">
<a href="{{ url_for('moderation') }}">Annuler</a>
</form>
{% endif %}
{% endblock %}

View File

@@ -21,6 +21,7 @@
<li><a href="/live-alert">Alerte live</a></li> <li><a href="/live-alert">Alerte live</a></li>
<li><a href="/commandes">Commandes</a></li> <li><a href="/commandes">Commandes</a></li>
<li><a href="/humeurs">Humeurs</a></li> <li><a href="/humeurs">Humeurs</a></li>
<li><a href="/moderation">Modération</a></li>
<li><a href="/protondb">ProtonDB</a></li> <li><a href="/protondb">ProtonDB</a></li>
<li><a href="/configurations">Configurations</a></li> <li><a href="/configurations">Configurations</a></li>
</ul> </ul>