26 Commits

Author SHA1 Message Date
skylanix
5c76b50797 Merge pull request #36 from skylanix Fonctionnalités de modération
Fonctionnalités de modération
2025-11-12 01:26:15 +00:00
skylanix
d5d3e45a62 Ajouts des fonctionnalitées 2025-11-12 02:09:21 +01:00
Mow
cb559c2863 Ajout de la commande !say pour permettre l'envoi de messages en tant que bot dans un canal spécifié, avec des instructions d'utilisation . 2025-11-11 22:38:22 +01:00
Mow
a0a14abf57 correction de la exclusion temporaire et ajout de la commande de time out !to 2025-11-11 22:23:30 +01:00
Mow
a987ca311e Ajout de la commande !timeout pour exclure temporairement un utilisateur avec une durée spécifiée, ainsi que des améliorations dans la gestion des avertissements, y compris l'envoi de messages privés de confirmation et la mise à jour des messages d'utilisation. 2025-11-11 22:11:21 +01:00
Mow
6411b1e73c Ajout d'une table member_invites dans la base de données pour suivre les invitations des membres, et mise à jour des commandes de modération pour supprimer les messages après un délai 2025-11-08 22:27:55 +01:00
Mow
3b2886a41f Channel logs + tout passe par un channel log 2025-11-08 21:47:27 +01:00
Mow
0e43313366 Rajout de compte suspect si l'utilisateur a crée son compte il y a moin de 7 jours dans le !inspect 2025-11-08 20:23:44 +01:00
Mow
0b9b9a4a23 Amélioration de la commande de kick pour inclure la possibilité d'utiliser un ID utilisateur, ajout de messages d'erreur pour les utilisateurs introuvables. 2025-11-08 20:18:49 +01:00
Mow
4a3cf400a0 Ajout des syntax a utiliser dans le welcome + la gestion des variable dans le welcome.py 2025-11-08 18:41:05 +01:00
Mow
c45f83df6c Ajout d'une fonction pour envoyer un message privé de confirmation aux modérateurs lors des actions de sanction (avertissement, bannissement, débannissement, expulsion) et mise à jour des messages d'embed pour virer la raison du départ. Modification du message de bienvenue pour inclure des instructions sur la mention des channels. 2025-11-08 17:41:11 +01:00
Mow910
8a194f7b0e Ajout d'une vérification de délai pour les actions de kick dans le message de départ des membres, avec un check de 3s 2025-11-06 20:03:05 +01:00
skylanix
30d0a4160b Corrections et déploiement avec portainer 2025-11-06 02:05:05 +00:00
skylanix
95edb9a523 Corrections et déploiement avec portainer 2025-11-06 02:00:39 +00:00
skylanix
81be00da28 Corrections et déploiement avec portainer 2025-11-06 01:58:25 +00:00
skylanix
9cdf26c3ba Corrections et déploiement avec portainer 2025-11-06 01:55:18 +00:00
skylanix
d63d81f2b8 Retire <> pour une meilleure compréhension 2025-11-06 01:24:17 +00:00
skylanix
a26214ed68 Améliore la communication et la documentation 2025-11-06 01:01:23 +00:00
Mow
18a883c27b Résolution de la gestion de la time zone
!aide ajouté
2025-11-02 20:05:17 +01:00
skylanix
eb9bf0e67e Améliore la communication moderation 2025-10-30 01:38:43 +01:00
Mow
db03c382cd Retrait du dossier logs du suivi Git et mise à jour du .gitignore 2025-10-26 18:43:11 +01:00
Mow
02abe1e1a7 Ajout de la gestion des rôles de modération dans le panneau d'administration. Mise à jour de la logique de configuration pour permettre la sélection de plusieurs rôles. Amélioration de l'interface utilisateur pour la gestion des rôles et des commandes de modération. Ajout de la mise à jour automatique du cache anti-cheat et de nouvelles fonctionnalités pour récupérer et stocker les données anti-cheat. 2025-10-26 14:55:16 +01:00
Mow
2815022219 Refont de l'interface configuration.html 2025-10-16 20:37:38 +02:00
Mow
6a171e795f Ajout de la gestion des messages de bienvenue et de départ pour les membres. Mise à jour des configurations pour activer ces fonctionnalités dans le panneau d'administration. 2025-10-16 00:04:39 +02:00
Mow
fd172e2ea0 Refonte du systeme d'avertissement et devient un moderation_event
Ajout de la commande !ban !kick !unban !listevent

Ajout du role ID dans le panel , et possibilité d'activé les commandes via le panel
2025-10-15 22:29:41 +02:00
Mow
aff236fd0c Ajouter un modèle d'avertissement et des fonctionnalités de modération
Ajout d'un nouveau modèle Warning dans database/models.py pour suivre les avertissements donnés aux utilisateurs.
Mise à jour de database/schema.sql pour créer la table correspondante 'warning'.
Amélioration du bot Discord pour gérer les commandes d'avertissement.
Ajout d'une route de modération dans webapp pour de futures fonctionnalités de modération.
Mise à jour de template.html pour inclure un lien vers la page de modération.
2025-10-14 22:37:28 +02:00
22 changed files with 2599 additions and 190 deletions

2
.gitignore vendored
View File

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

View File

@@ -5,7 +5,6 @@ WORKDIR /app
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
ENV LANG=fr_FR.UTF-8 ENV LANG=fr_FR.UTF-8
ENV LC_ALL=fr_FR.UTF-8 ENV LC_ALL=fr_FR.UTF-8
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
apt-utils \ apt-utils \
@@ -35,7 +34,7 @@ RUN python3 -m venv /app/venv && \
chmod +x /start.sh && \ chmod +x /start.sh && \
mkdir -p /app/logs mkdir -p /app/logs
HEALTHCHECK --interval=1s --timeout=10s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD pgrep python > /dev/null && ! (tail -n 1000 $(ls -t /app/logs/*.log 2>/dev/null | head -1) 2>/dev/null | grep -iE "(ERROR|CRITICAL|Exception|sqlite3\.OperationalError)") CMD pgrep python > /dev/null && ! (tail -n 1000 $(ls -t /app/logs/*.log 2>/dev/null | head -1) 2>/dev/null | grep -iE "(ERROR|CRITICAL|Exception|sqlite3\.OperationalError)")
CMD ["/start.sh"] CMD ["/start.sh"]

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,20 +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():
try: self.run(token.value)
self.run(token.value)
except Exception as e:
logging.error(f'Erreur fatale lors du démarrage du bot Discord : {e}')
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
@@ -66,37 +97,150 @@ 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:
await asyncio.wait_for(message.channel.send(commande.response, suppress_embeds=True), timeout=30.0) await message.channel.send(commande.response, suppress_embeds=True)
return return
except asyncio.TimeoutError:
logging.error(f'Timeout lors de l\'envoi de la commande Discord : {command_name}')
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 asyncio.wait_for(message.channel.send(msg, suppress_embeds=True), timeout=30.0)
except asyncio.TimeoutError:
logging.error(f'Timeout lors de l\'envoi du message ProtonDB')
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)

View File

@@ -1,4 +1,3 @@
import asyncio
import datetime import datetime
import logging import logging
import json import json
@@ -15,18 +14,11 @@ def _isEnable():
return helper.getValue('humble_bundle_enable') and helper.getIntValue('humble_bundle_channel') != 0 return helper.getValue('humble_bundle_enable') and helper.getIntValue('humble_bundle_channel') != 0
def _callGithub(): def _callGithub():
try: response = requests.get("https://raw.githubusercontent.com/shionn/HumbleBundleGamePack/refs/heads/master/data/game-bundles.json")
response = requests.get("https://raw.githubusercontent.com/shionn/HumbleBundleGamePack/refs/heads/master/data/game-bundles.json", timeout=30) if response.status_code == 200:
if response.status_code == 200: return response.json()
return response.json() logging.error(f"Échec de la connexion à la ressource Humble Bundle. Code de statut HTTP : {response.status_code}")
logging.error(f"Échec de la connexion à la ressource Humble Bundle. Code de statut HTTP : {response.status_code}") return None
return None
except (requests.exceptions.SSLError, requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
logging.error(f"Erreur de connexion à la ressource Humble Bundle : {e}")
return None
except Exception as e:
logging.error(f"Erreur inattendue lors de la récupération des bundles : {e}")
return None
def _isNotAlreadyNotified(bundle): def _isNotAlreadyNotified(bundle):
return GameBundle.query.filter_by(url=bundle['url']).first() == None return GameBundle.query.filter_by(url=bundle['url']).first() == None
@@ -54,14 +46,9 @@ async def checkHumbleBundleAndNotify(bot: Client):
bundle = _findFirstNotNotified(bundles) bundle = _findFirstNotNotified(bundles)
if bundle != None : if bundle != None :
message = _formatMessage(bundle) message = _formatMessage(bundle)
try: await bot.get_channel(ConfigurationHelper().getIntValue('humble_bundle_channel')).send(message)
await asyncio.wait_for(bot.get_channel(ConfigurationHelper().getIntValue('humble_bundle_channel')).send(message), timeout=30.0) db.session.add(GameBundle(url=bundle['url'], name=bundle['name'], json = json.dumps(bundle)))
db.session.add(GameBundle(url=bundle['url'], name=bundle['name'], json = json.dumps(bundle))) db.session.commit()
db.session.commit()
except asyncio.TimeoutError:
logging.error(f'Timeout lors de l\'envoi du message Humble Bundle')
except Exception as send_error:
logging.error(f'Erreur lors de l\'envoi du message Humble Bundle : {send_error}')
except Exception as e: except Exception as e:
logging.error(f"Échec de la vérification des offres Humble Bundle : {e}") logging.error(f"Échec de la vérification des offres Humble Bundle : {e}")
else: else:

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,45 +1,33 @@
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):
try: config = SearchConfig(ConfigurationHelper().getValue('proton_db_api_id'),
config = SearchConfig(ConfigurationHelper().getValue('proton_db_api_id'), ConfigurationHelper().getValue('proton_db_api_key'))
ConfigurationHelper().getValue('proton_db_api_key')) config.set_default_hosts()
config.set_default_hosts() client = SearchClientSync(config=config)
client = SearchClientSync(config=config) return client.search_single_index(index_name="steamdb",
return client.search_single_index(index_name="steamdb", search_params={
search_params={ "query":search_name,
"query":search_name, "facetFilters":[["appType:Game"]],
"facetFilters":[["appType:Game"]], "hitsPerPage":50},
"hitsPerPage":50}, request_options= {'headers':{'Referer':'https://www.protondb.com/'}})
request_options= {
'headers':{'Referer':'https://www.protondb.com/'},
'timeout': 30
})
except Exception as e:
logging.error(f'Erreur lors de la recherche Algolia pour "{search_name}" : {e}')
return None
def _call_summary(id): def _call_summary(id):
try: response = requests.get(f'http://jazzy-starlight-aeea19.netlify.app/api/v1/reports/summaries/{id}.json')
response = requests.get(f'http://jazzy-starlight-aeea19.netlify.app/api/v1/reports/summaries/{id}.json', timeout=30) if (response.status_code == 200) :
if (response.status_code == 200) : return response.json()
return response.json() logging.error(f'Échec de la récupération des données ProtonDB pour le jeu {id}. Code de statut HTTP : {response.status_code}')
logging.error(f'Échec de la récupération des données ProtonDB pour le jeu {id}. Code de statut HTTP : {response.status_code}') return None
return None
except (requests.exceptions.SSLError, requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
logging.error(f'Erreur de connexion ProtonDB pour le jeu {id} : {e}')
return None
except Exception as e:
logging.error(f'Erreur inattendue lors de la récupération ProtonDB pour le jeu {id} : {e}')
return None
def _is_name_match(name:str, search_name:str) -> bool: def _is_name_match(name:str, search_name:str) -> bool:
normalized_game_name = re.sub("[^a-z0-9]", "", name.lower()) normalized_game_name = re.sub("[^a-z0-9]", "", name.lower())
@@ -51,12 +39,131 @@ 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 searhProtonDb(search_name:str): 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 = [] 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)
if responses is None:
return results
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')
name:str = hit.get('name') name:str = hit.get('name')
@@ -65,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

@@ -37,16 +37,14 @@ class TwitchBot() :
if _isConfigured() : if _isConfigured() :
try : try :
helper = ConfigurationHelper() helper = ConfigurationHelper()
self.twitch = await asyncio.wait_for(Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret')), timeout=30.0) self.twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
await asyncio.wait_for(self.twitch.set_user_authentication(helper.getValue('twitch_access_token'), USER_SCOPE, helper.getValue('twitch_refresh_token')), timeout=30.0) await self.twitch.set_user_authentication(helper.getValue('twitch_access_token'), USER_SCOPE, helper.getValue('twitch_refresh_token'))
self.chat = await asyncio.wait_for(Chat(self.twitch), timeout=30.0) self.chat = await Chat(self.twitch)
self.chat.register_event(ChatEvent.READY, _onReady) self.chat.register_event(ChatEvent.READY, _onReady)
self.chat.register_event(ChatEvent.MESSAGE, _onMessage) self.chat.register_event(ChatEvent.MESSAGE, _onMessage)
# chat.register_event(ChatEvent.SUB, on_sub) # chat.register_event(ChatEvent.SUB, on_sub)
self.chat.register_command('hello', _helloCommand) self.chat.register_command('hello', _helloCommand)
self.chat.start() self.chat.start()
except asyncio.TimeoutError:
logging.error('Timeout lors de la connexion à Twitch. Vérifiez votre connexion réseau.')
except Exception as e: except Exception as e:
logging.error(f'Échec de l\'authentification Twitch. Vérifiez vos identifiants et redémarrez après correction : {e}') logging.error(f'Échec de l\'authentification Twitch. Vérifiez vos identifiants et redémarrez après correction : {e}')
else: else:

View File

@@ -1,4 +1,3 @@
import asyncio
import logging import logging
from twitchAPI.twitch import Twitch from twitchAPI.twitch import Twitch
@@ -37,24 +36,14 @@ async def _notifyAlert(alert : LiveAlert, stream : Stream):
async def _sendMessage(channel : int, message : str) : async def _sendMessage(channel : int, message : str) :
logger.info(f'Envoi de notification : {message}') logger.info(f'Envoi de notification : {message}')
try: await bot.get_channel(channel).send(message)
await asyncio.wait_for(bot.get_channel(channel).send(message), timeout=30.0) logger.info(f'Notification envoyé')
logger.info(f'Notification envoyée')
except asyncio.TimeoutError:
logger.error(f'Timeout lors de l\'envoi de notification live alert')
except Exception as e:
logger.error(f'Erreur lors de l\'envoi de notification live alert : {e}')
async def _retreiveStreams(twitch: Twitch, alerts : list[LiveAlert]) -> list[Stream] : async def _retreiveStreams(twitch: Twitch, alerts : list[LiveAlert]) -> list[Stream] :
streams : list[Stream] = [] streams : list[Stream] = []
logger.info(f'Recherche de streams pour : {alerts}') logger.info(f'Recherche de streams pour : {alerts}')
try: async for stream in twitch.get_streams(user_login = [alert.login for alert in alerts]):
async for stream in asyncio.wait_for(twitch.get_streams(user_login = [alert.login for alert in alerts]), timeout=30.0): streams.append(stream)
streams.append(stream) logger.info(f'Ces streams sont en ligne : {streams}')
logger.info(f'Ces streams sont en ligne : {streams}')
except asyncio.TimeoutError:
logger.error('Timeout lors de la récupération des streams Twitch')
except Exception as e:
logger.error(f'Erreur lors de la récupération des streams Twitch : {e}')
return streams return streams

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>

View File

@@ -17,53 +17,34 @@ auth: UserAuthenticator
def twitchConfigurationHelp(): def twitchConfigurationHelp():
return render_template("twitch-aide.html", token_redirect_url = _buildUrl()) return render_template("twitch-aide.html", token_redirect_url = _buildUrl())
@webapp.route("/configurations/twitch/request-token") @webapp.route("/configurations/twitch/request-token")
async def twitchRequestToken(): async def twitchRequestToken():
global auth global auth
try: helper = ConfigurationHelper()
helper = ConfigurationHelper() twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
import asyncio auth = UserAuthenticator(twitch, USER_SCOPE, url=_buildUrl())
twitch = await asyncio.wait_for( return redirect(auth.return_auth_url())
Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret')),
timeout=30.0
)
auth = UserAuthenticator(twitch, USER_SCOPE, url=_buildUrl())
return redirect(auth.return_auth_url())
except asyncio.TimeoutError:
logging.error('Timeout lors de la connexion à Twitch API pour la demande de token')
return redirect(url_for('openConfigurations'))
except TwitchAPIException as e:
logging.error(f'Erreur API Twitch lors de la demande de token : {e}')
return redirect(url_for('openConfigurations'))
except Exception as e:
logging.error(f'Erreur inattendue lors de la demande de token Twitch : {e}')
return redirect(url_for('openConfigurations'))
@webapp.route("/configurations/twitch/receive-token") @webapp.route("/configurations/twitch/receive-token")
async def twitchReceiveToken(): async def twitchReceiveToken():
global auth global auth
state = request.args.get('state') state = request.args.get('state')
code = request.args.get('code') code = request.args.get('code')
if state != auth.state : if state != auth.state :
logging.error('bad returned state') logging('bad returned state')
return redirect(url_for('openConfigurations')) return redirect(url_for('openConfigurations'))
if code == None : if code == None :
logging.error('no returned code') logging('no returned state')
return redirect(url_for('openConfigurations')) return redirect(url_for('openConfigurations'))
try: try:
import asyncio token, refresh = await auth.authenticate(user_token=code)
token, refresh = await asyncio.wait_for(auth.authenticate(user_token=code), timeout=30.0)
helper = ConfigurationHelper() helper = ConfigurationHelper()
helper.createOrUpdate('twitch_access_token', token) helper.createOrUpdate('twitch_access_token', token)
helper.createOrUpdate('twitch_refresh_token', refresh) helper.createOrUpdate('twitch_refresh_token', refresh)
db.session.commit() db.session.commit()
except asyncio.TimeoutError:
logging.error('Timeout lors de l\'authentification Twitch')
except TwitchAPIException as e: except TwitchAPIException as e:
logging.error(f'Erreur API Twitch lors de l\'authentification : {e}') logging(e)
except Exception as e:
logging.error(f'Erreur inattendue lors de l\'authentification Twitch : {e}')
return redirect(url_for('openConfigurations')) return redirect(url_for('openConfigurations'))
# hack pas fou mais on estime qu'on sera toujours en ssl en connecté # hack pas fou mais on estime qu'on sera toujours en ssl en connecté