1 Commits

Author SHA1 Message Date
skylanix
45347be85b Améliore le healthcheck Docker pour détecter les erreurs en temps réel 2025-10-07 02:22:50 +02:00
30 changed files with 1101 additions and 3527 deletions

2
.gitignore vendored
View File

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

View File

@@ -5,6 +5,7 @@ WORKDIR /app
ENV DEBIAN_FRONTEND=noninteractive
ENV LANG=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 \
apt-utils \
@@ -34,7 +35,7 @@ RUN python3 -m venv /app/venv && \
chmod +x /start.sh && \
mkdir -p /app/logs
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
HEALTHCHECK --interval=1s --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 ["/start.sh"]

196
README.md
View File

@@ -14,8 +14,6 @@
- [Prérequis](#prérequis)
- [Création du bot Discord](#création-du-bot-discord)
- [Démarrage rapide](#démarrage-rapide)
- [Build local (développement)](#build-local-développement)
- [Déploiement avec Portainer](#déploiement-avec-portainer)
- [Volumes persistants](#volumes-persistants)
- [Commandes Docker utiles](#commandes-docker-utiles)
- [Mise à jour](#mise-à-jour)
@@ -52,49 +50,12 @@ Mamie Henriette est un bot intelligent open-source développé spécifiquement p
- **Statuts dynamiques** : Rotation automatique des humeurs (10 min)
- **Notifications Humble Bundle** : Surveillance et alertes automatiques (30 min)
- **Commandes personnalisées** : Gestion via interface web
- **Recherche ProtonDB** :
- Commande `!protondb 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
- **Recherche ProtonDB** : Commande `!protondb <nom_du_jeu>` pour vérifier la compatibilité Linux/Steam Deck
- **Modération** : Outils intégrés
### Twitch
- **Chat bot** : Commandes et interactions automatiques
- **Alertes Live** :
- Surveillance automatique des streamers
- **Alertes Live** : Surveillance automatique des streamers (vérification toutes les 5 minutes)
- Support jusqu'à 100 chaînes simultanément
- Notifications Discord avec aperçu du stream
- Gestion via interface d'administration
@@ -106,23 +67,10 @@ Mamie Henriette est un bot intelligent open-source développé spécifiquement p
### Interface d'administration
- **Dashboard** : Vue d'ensemble et statistiques
- **Configuration** :
- Tokens Discord/Twitch et paramètres des plateformes
- Configuration ProtonDB (API Algolia)
- Gestion des rôles staff (support de multiples rôles)
- Activation/désactivation individuelle des fonctionnalités (modération, ban, kick, welcome, leave)
- Configuration du délai de suppression automatique des messages de modération
- **Gestion des humeurs** : Création et modification des statuts Discord rotatifs
- **Commandes** : Édition des commandes personnalisées multi-plateformes
- **Modération** :
- Consultation de l'historique complet des sanctions
- Édition des raisons des événements de modération
- Suppression d'événements de modération
- Filtrage et recherche dans l'historique
- **Messages de bienvenue/départ** :
- Personnalisation des messages avec variables dynamiques
- Configuration des canaux de bienvenue et départ
- Activation/désactivation indépendante
- **Configuration** : Tokens, paramètres des plateformes, configuration ProtonDB
- **Gestion des humeurs** : Création et modification des statuts
- **Commandes** : Édition des commandes personnalisées
- **Modération** : Outils de gestion communautaire
## Installation
@@ -167,122 +115,21 @@ Avant d'installer MamieHenriette, vous devez créer un bot Discord et obtenir so
```bash
# 1. Cloner le projet
git clone https://github.com/skylanix/MamieHenriette.git
```
```bash
cd MamieHenriette
```
```bash
# 2. Récupérer l'image depuis GitHub Container Registry et lancer
docker compose pull
docker compose up -d
```
> 📝 L'interface web sera accessible sur http://localhost:5000
>
> ⚠️ **Important** : Après configuration via l'interface web, **redémarrez le conteneur** pour que les changements soient pris en compte :
> ```bash
> docker compose restart MamieHenriette
> ```
### Build local (développement)
Si vous souhaitez modifier le code et builder l'image localement :
```bash
# 1. Cloner et accéder au projet
git clone https://github.com/skylanix/MamieHenriette.git
cd MamieHenriette
```
```bash
# 2. Modifier le docker-compose.yml
# Commentez la ligne 'image:' et décommentez la section 'build:' :
```
```yaml
services:
mamiehenriette:
container_name: MamieHenriette
restart: unless-stopped
build: . # ← Décommentez cette ligne
image: mamiehenriette # ← Décommentez cette ligne
# image: ghcr.io/skylanix/mamiehenriette:latest # ← Commentez cette ligne
# ... reste de la configuration
```
```bash
# 3. Builder et lancer
# 2. Lancer avec Docker
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
> ⚠️ **Important** : Après configuration via l'interface web http://localhost:5000, **redémarrez le conteneur** pour que les changements soient pris en compte :
> ```bash
> docker compose restart MamieHenriette
> ```
### Volumes persistants
- `./instance/` : Base de données SQLite et configuration
@@ -318,12 +165,10 @@ git pull origin main
# 3. Mettre à jour l'image Docker
docker compose pull
# 4. Relancer
docker compose up -d
# 4. Reconstruire et relancer
docker compose up --build -d
```
> 💡 **Note** : Si vous utilisez Watchtower, les mises à jour de l'image sont automatiques (vérification toutes les 30 minutes).
#### Sans Docker (installation locale)
```bash
# 1. Arrêter l'application
@@ -391,16 +236,13 @@ python run-web.py
## Spécifications techniques
### Base de données (SQLite)
- **Configuration** : Paramètres et tokens des plateformes, configuration des fonctionnalités
- **Configuration** : Paramètres et tokens des plateformes
- **Humeur** : Statuts Discord rotatifs avec gestion automatique
- **Commande** : Commandes personnalisées multi-plateformes (Discord/Twitch)
- **LiveAlert** : Configuration surveillance streamers Twitch (nom, canal Discord, statut)
- **GameAlias** : Alias pour améliorer les recherches ProtonDB
- **GameBundle** : Historique et notifications Humble Bundle
- **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)
- **Message** : Messages automatiques périodiques (implémenté)
### Architecture multi-thread
- **Thread 1** : Interface web Flask (port 5000) avec logging rotatif

View File

@@ -1,8 +1,6 @@
import logging
import json
import os
from sqlalchemy import event
from sqlalchemy.engine import Engine
from flask_sqlalchemy import SQLAlchemy
from sqlite3 import Cursor, Connection
@@ -11,28 +9,8 @@ from webapp import webapp
basedir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
webapp.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(basedir, "instance", "database.db")}'
# Options moteur pour améliorer la concurrence SQLite
webapp.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'connect_args': {
'check_same_thread': False,
'timeout': 30
},
}
webapp.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(webapp)
# PRAGMA pour SQLite (WAL, busy timeout)
@event.listens_for(Engine, "connect")
def _set_sqlite_pragma(dbapi_connection, connection_record):
try:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL;")
cursor.execute("PRAGMA synchronous=NORMAL;")
cursor.execute("PRAGMA busy_timeout=30000;")
cursor.close()
except Exception:
pass
def _tableHaveColumn(table_name:str, column_name:str, cursor:Cursor) -> bool:
cursor.execute(f'PRAGMA table_info({table_name})')
columns = cursor.fetchall()

View File

@@ -40,24 +40,3 @@ class Commande(db.Model):
trigger = db.Column(db.String(32), unique=True)
response = db.Column(db.String(2000))
class ModerationEvent(db.Model):
id = db.Column(db.Integer, primary_key=True)
type = db.Column(db.String(32))
username = db.Column(db.String(256))
discord_id = db.Column(db.String(64))
created_at = db.Column(db.DateTime)
reason = db.Column(db.String(1024))
staff_id = db.Column(db.String(64))
staff_name = db.Column(db.String(256))
duration = db.Column(db.Integer)
class AntiCheatCache(db.Model):
__tablename__ = 'anticheat_cache'
steam_id = db.Column(db.String(32), primary_key=True)
game_name = db.Column(db.String(256))
status = db.Column(db.String(32))
anticheats = db.Column(db.String(512))
reference = db.Column(db.String(512))
notes = db.Column(db.String(1024))
updated_at = db.Column(db.DateTime)

View File

@@ -45,34 +45,3 @@ CREATE TABLE IF NOT EXISTS `commande` (
`trigger` VARCHAR(16) UNIQUE NOT NULL,
`response` VARCHAR(2000) NOT NULL
);
CREATE TABLE IF NOT EXISTS `moderation_event` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`type` VARCHAR(32) NOT NULL,
`username` VARCHAR(256) NOT NULL,
`discord_id` VARCHAR(64) NOT NULL,
`created_at` DATETIME NOT NULL,
`reason` VARCHAR(1024) NOT NULL,
`staff_id` VARCHAR(64) NOT NULL,
`staff_name` VARCHAR(256) NOT NULL,
`duration` INTEGER NULL
);
CREATE TABLE IF NOT EXISTS `anticheat_cache` (
steam_id VARCHAR(32) PRIMARY KEY,
game_name VARCHAR(256) NOT NULL,
status VARCHAR(32) NOT NULL,
anticheats VARCHAR(512),
reference VARCHAR(512),
notes VARCHAR(1024),
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS `member_invites` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`user_id` VARCHAR(64) NOT NULL,
`guild_id` VARCHAR(64) NOT NULL,
`invite_code` VARCHAR(256),
`inviter_name` VARCHAR(256),
`join_date` DATETIME NOT NULL
);

View File

@@ -6,22 +6,8 @@ import random
from database import db
from database.helpers import ConfigurationHelper
from database.models import Configuration, Humeur, Commande
from discord import Message, TextChannel, Member
from discord import Message, TextChannel
from discordbot.humblebundle import checkHumbleBundleAndNotify
from discordbot.moderation import (
handle_warning_command,
handle_remove_warning_command,
handle_list_warnings_command,
handle_ban_command,
handle_kick_command,
handle_unban_command,
handle_inspect_command,
handle_ban_list_command,
handle_staff_help_command,
handle_timeout_command,
handle_say_command
)
from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
from protondb import searhProtonDb
class DiscordBot(discord.Client):
@@ -30,9 +16,6 @@ class DiscordBot(discord.Client):
for c in self.get_all_channels() :
logging.info(f'{c.id} {c.name}')
for guild in self.guilds:
await updateInviteCache(guild)
self.loop.create_task(self.updateStatus())
self.loop.create_task(self.updateHumbleBundle())
@@ -44,11 +27,13 @@ class DiscordBot(discord.Client):
if humeur != None:
logging.info(f'Changement de statut : {humeur.text}')
await self.change_presence(status = discord.Status.online, activity = discord.CustomActivity(humeur.text))
# 10 minutes TODO à rendre configurable
await asyncio.sleep(10*60)
async def updateHumbleBundle(self):
while not self.is_closed():
await checkHumbleBundleAndNotify(self)
# toutes les 30 minutes
await asyncio.sleep(30*60)
def getAllTextChannel(self) -> list[TextChannel]:
@@ -58,33 +43,19 @@ class DiscordBot(discord.Client):
channels.append(channel)
return channels
def getAllRoles(self):
guilds_roles = []
for guild in self.guilds:
roles = []
for role in guild.roles:
if role.name != "@everyone":
roles.append(role)
if roles:
guilds_roles.append({
'guild_name': guild.name,
'guild_id': guild.id,
'roles': roles
})
return guilds_roles
def begin(self) :
token = Configuration.query.filter_by(key='discord_token').first()
if token and token.value and token.value.strip():
if token :
try:
self.run(token.value)
except Exception as e:
logging.error(f'Erreur fatale lors du démarrage du bot Discord : {e}')
else :
logging.error('Aucun token Discord configuré. Le bot ne peut pas être démarré')
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
intents.invites = True
bot = DiscordBot(intents=intents)
# https://discordpy.readthedocs.io/en/stable/quickstart.html
@@ -95,172 +66,37 @@ async def on_message(message: Message):
if not message.content.startswith('!'):
return
command_name = message.content.split()[0]
if ConfigurationHelper().getValue('moderation_enable'):
if command_name in ['!averto', '!av', '!avertissement', '!warn']:
await handle_warning_command(message, bot)
return
if command_name in ['!to', '!timeout']:
await handle_timeout_command(message, bot)
return
if command_name in ['!delaverto', '!removewarn', '!unwarn']:
await handle_remove_warning_command(message, bot)
return
if command_name in ['!listevent', '!listwarn', '!warnings']:
await handle_list_warnings_command(message, bot)
return
if ConfigurationHelper().getValue('moderation_ban_enable'):
if command_name == '!ban':
await handle_ban_command(message, bot)
return
if command_name == '!unban':
await handle_unban_command(message, bot)
return
if command_name == '!banlist':
await handle_ban_list_command(message, bot)
return
if ConfigurationHelper().getValue('moderation_kick_enable'):
if command_name == '!kick':
await handle_kick_command(message, bot)
return
if ConfigurationHelper().getValue('moderation_enable'):
if command_name == '!inspect':
await handle_inspect_command(message, bot)
return
if command_name == '!say':
await handle_say_command(message, bot)
return
if command_name in ['!aide', '!help']:
await handle_staff_help_command(message, bot)
return
commande = Commande.query.filter_by(discord_enable=True, trigger=command_name).first()
if commande:
try:
await message.channel.send(commande.response, suppress_embeds=True)
await asyncio.wait_for(message.channel.send(commande.response, suppress_embeds=True), timeout=30.0)
return
except asyncio.TimeoutError:
logging.error(f'Timeout lors de l\'envoi de la commande Discord : {command_name}')
except Exception as e:
logging.error(f'Échec de l\'exécution de la commande Discord : {e}')
if (ConfigurationHelper().getValue('proton_db_enable_enable') and (message.content.startswith('!protondb') or message.content.startswith('!pdb'))):
if(ConfigurationHelper().getValue('proton_db_enable_enable') and message.content.find('!protondb')==0) :
if (message.content.find('<@')>0) :
mention = message.content[message.content.find('<@'):]
else :
mention = message.author.mention
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();
if not name or len(name) == 0:
try:
await message.delete()
delete_time = ConfigurationHelper().getIntValue('proton_db_delete_time') or 10
help_msg = await message.channel.send(
f"{mention} ⚠️ Utilisation: `!pdb nom du jeu` ou `!protondb nom du jeu`\n"
f"Exemple: `!pdb Elden Ring`",
suppress_embeds=True
)
await asyncio.sleep(delete_time)
await help_msg.delete()
except Exception as e:
logging.error(f"Échec de la gestion du message d'aide ProtonDB : {e}")
return
try:
searching_msg = await message.channel.send(f"🔍 Recherche en cours pour **{name}**...")
name = message.content.replace('!protondb', '').replace(f'{mention}', '').strip();
games = searhProtonDb(name)
await searching_msg.delete()
except:
games = searhProtonDb(name)
if (len(games)==0) :
msg = f'{mention} Je n\'ai pas trouvé de jeux correspondant à **{name}**. Es-tu sûr que le jeu est disponible sur Steam ?'
try:
await message.channel.send(msg, suppress_embeds=True)
except Exception as e:
logging.error(f"Échec de l'envoi du message ProtonDB : {e}")
return
total_games = len(games)
tier_colors = {'platinum': '🟣', 'gold': '🟡', 'silver': '', 'bronze': '🟤', 'borked': '🔴'}
content = ""
max_games = 15
for count, game in enumerate(games[:max_games]):
g_name = str(game.get('name'))
g_id = str(game.get('id'))
tier = str(game.get('tier') or 'N/A').lower()
tier_icon = tier_colors.get(tier, '')
new_entry = f"**[{g_name}](<https://www.protondb.com/app/{g_id}>)**\n{tier_icon} Classé **{tier.capitalize()}**"
ac_status = game.get('anticheat_status')
if ac_status:
status_lower = str(ac_status).lower()
ac_map = {
'supported': ('', 'Supporté'),
'running': ('⚠️', 'Fonctionne'),
'broken': ('', 'Cassé'),
'denied': ('🚫', 'Refusé'),
'planned': ('📅', 'Planifié')
}
ac_emoji, ac_label = ac_map.get(status_lower, ('', str(ac_status)))
acs = game.get('anticheats') or []
ac_list = ', '.join([str(ac) for ac in acs if ac])
new_entry += f" • [Anti-cheat {ac_emoji} {ac_label}"
if ac_list:
new_entry += f" ({ac_list})"
new_entry += f"](<https://areweanticheatyet.com/game/{g_id}>)"
new_entry += "\n\n"
# Vérifier la limite avant d'ajouter
if len(content) + len(new_entry) > 3900:
rest = len(games) - count
content += f"*... et {rest} autre{'s' if rest > 1 else ''} jeu{'x' if rest > 1 else ''}*"
break
content += new_entry
else:
rest = max(0, len(games) - max_games)
if rest > 0:
content += f"*... et {rest} autre{'s' if rest > 1 else ''} jeu{'x' if rest > 1 else ''}*"
embed = discord.Embed(
title=f"🎮 Résultats ProtonDB - **{total_games} jeu{'x' if total_games > 1 else ''} trouvé{'s' if total_games > 1 else ''}**",
description=content,
color=0x5865F2
)
else :
msg = f'{mention} J\'ai trouvé {len(games)} jeux :\n'
ite = iter(games)
while (game := next(ite, None)) is not None and len(msg) < 1850 :
msg += f'- [{game.get('name')}](https://www.protondb.com/app/{game.get('id')}) classé **{game.get('tier')}**\n'
rest = sum(1 for _ in ite)
if (rest > 0):
msg += f'- et encore {rest} autres jeux'
try :
await message.channel.send(embed=embed)
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 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)
logging.error(f'Échec de l\'envoi du message ProtonDB : {e}')

View File

@@ -1,3 +1,4 @@
import asyncio
import datetime
import logging
import json
@@ -14,11 +15,18 @@ def _isEnable():
return helper.getValue('humble_bundle_enable') and helper.getIntValue('humble_bundle_channel') != 0
def _callGithub():
response = requests.get("https://raw.githubusercontent.com/shionn/HumbleBundleGamePack/refs/heads/master/data/game-bundles.json")
try:
response = requests.get("https://raw.githubusercontent.com/shionn/HumbleBundleGamePack/refs/heads/master/data/game-bundles.json", timeout=30)
if response.status_code == 200:
return response.json()
logging.error(f"Échec de la connexion à la ressource Humble Bundle. Code de statut HTTP : {response.status_code}")
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):
return GameBundle.query.filter_by(url=bundle['url']).first() == None
@@ -46,9 +54,14 @@ async def checkHumbleBundleAndNotify(bot: Client):
bundle = _findFirstNotNotified(bundles)
if bundle != None :
message = _formatMessage(bundle)
await bot.get_channel(ConfigurationHelper().getIntValue('humble_bundle_channel')).send(message)
try:
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.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:
logging.error(f"Échec de la vérification des offres Humble Bundle : {e}")
else:

File diff suppressed because it is too large Load Diff

View File

@@ -1,198 +0,0 @@
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.name, 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:
message = await channel.send(embed=embed)
logging.info(f'Message de bienvenue envoyé pour {member.name}')
now = datetime.now(timezone.utc)
account_age = (now - member.created_at).days
if account_age < 7:
await message.add_reaction('⚠️')
logging.info(f'Réaction warning ajoutée pour {member.name} (compte créé il y a {account_age} jours)')
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.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:
mamiehenriette:
container_name: MamieHenriette # Nom du conteneur
image: ghcr.io/skylanix/mamiehenriette:latest # Image hébergée sur GitHub Container Registry
restart: unless-stopped # Redémarre automatiquement sauf si arrêté manuellement
# build: . # Build du conteneur à partir d'un Dockerfile local (décommentez si nécessaire)
# image: mamiehenriette # Build du conteneur à partir d'un Dockerfile local (décommentez si nécessaire)
image: ghcr.io/skylanix/mamiehenriette:latest # Image hébergée sur GitHub Container Registry (commentez si nécessaire)
environment:
TZ: Europe/Paris # Fuseau horaire

View File

@@ -1,16 +1,15 @@
import logging
import requests
import re
import json
from datetime import datetime, timedelta
from algoliasearch.search.client import SearchClientSync, SearchConfig
from database import db
from database.helpers import ConfigurationHelper
from database.models import GameAlias, AntiCheatCache, Configuration
from sqlalchemy import desc, func
from database.models import GameAlias
from sqlalchemy import desc,func
def _call_algoliasearch(search_name:str):
try:
config = SearchConfig(ConfigurationHelper().getValue('proton_db_api_id'),
ConfigurationHelper().getValue('proton_db_api_key'))
config.set_default_hosts()
@@ -20,14 +19,27 @@ def _call_algoliasearch(search_name:str):
"query":search_name,
"facetFilters":[["appType:Game"]],
"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):
response = requests.get(f'http://jazzy-starlight-aeea19.netlify.app/api/v1/reports/summaries/{id}.json')
try:
response = requests.get(f'http://jazzy-starlight-aeea19.netlify.app/api/v1/reports/summaries/{id}.json', timeout=30)
if (response.status_code == 200) :
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}')
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:
normalized_game_name = re.sub("[^a-z0-9]", "", name.lower())
@@ -39,131 +51,12 @@ def _apply_game_aliases(search_name:str) -> str:
search_name = re.sub(re.escape(alias.alias), alias.name, search_name, flags=re.IGNORECASE)
return search_name
def _should_update_anticheat_cache() -> bool:
try:
last_update_conf = Configuration.query.filter_by(key='anticheat_last_update').first()
if not last_update_conf:
return True
try:
last_update = datetime.fromisoformat(last_update_conf.value)
return datetime.now() - last_update > timedelta(days=7)
except:
return True
except Exception as e:
logging.error(f'Erreur lors de la vérification du cache anti-cheat: {e}')
return False
def _fetch_anticheat_data():
try:
url = 'https://raw.githubusercontent.com/AreWeAntiCheatYet/AreWeAntiCheatYet/master/games.json'
response = requests.get(url, timeout=10)
if response.status_code == 200:
return response.json()
else:
logging.error(f'Échec de la récupération des données anti-cheat. Code HTTP: {response.status_code}')
return None
except Exception as e:
logging.error(f'Erreur lors de la récupération des données anti-cheat: {e}')
return None
def _update_anticheat_cache_if_needed():
try:
if not _should_update_anticheat_cache():
return
logging.info('Mise à jour du cache anti-cheat...')
anticheat_data = _fetch_anticheat_data()
if not anticheat_data:
return
for game in anticheat_data:
try:
steam_id = str(game.get('storeIds', {}).get('steam', ''))
if not steam_id or steam_id == '0':
continue
cache_entry = AntiCheatCache.query.filter_by(steam_id=steam_id).first()
status = game.get('status', 'Unknown')
anticheats_list = game.get('anticheats', [])
anticheats_str = json.dumps(anticheats_list) if anticheats_list else None
reference = game.get('reference', '')
notes_data = game.get('notes', '')
if isinstance(notes_data, list):
notes = json.dumps(notes_data)
else:
notes = str(notes_data) if notes_data else ''
game_name = game.get('name', '')
if cache_entry:
cache_entry.game_name = game_name
cache_entry.status = status
cache_entry.anticheats = anticheats_str
cache_entry.reference = reference
cache_entry.notes = notes
cache_entry.updated_at = datetime.now()
else:
cache_entry = AntiCheatCache(
steam_id=steam_id,
game_name=game_name,
status=status,
anticheats=anticheats_str,
reference=reference,
notes=notes,
updated_at=datetime.now()
)
db.session.add(cache_entry)
except Exception as e:
logging.error(f'Erreur lors de la mise à jour du jeu {game.get("name")}: {e}')
continue
last_update_conf = Configuration.query.filter_by(key='anticheat_last_update').first()
if last_update_conf:
last_update_conf.value = datetime.now().isoformat()
else:
last_update_conf = Configuration(key='anticheat_last_update', value=datetime.now().isoformat())
db.session.add(last_update_conf)
db.session.commit()
logging.info('Cache anti-cheat mis à jour avec succès')
except Exception as e:
try:
db.session.rollback()
except:
pass
logging.error(f'Erreur lors de la mise à jour du cache anti-cheat: {e}')
def _get_anticheat_info(steam_id: str) -> dict:
try:
cache_entry = AntiCheatCache.query.filter_by(steam_id=steam_id).first()
if not cache_entry:
return None
try:
anticheats = json.loads(cache_entry.anticheats) if cache_entry.anticheats else []
except:
anticheats = []
return {
'status': cache_entry.status,
'anticheats': anticheats,
'reference': cache_entry.reference,
'notes': cache_entry.notes
}
except Exception as e:
logging.error(f'Erreur lors de la récupération des infos anti-cheat pour {steam_id}: {e}')
return None
def searhProtonDb(search_name:str):
results = []
search_name = _apply_game_aliases(search_name)
try:
_update_anticheat_cache_if_needed()
except Exception as e:
logging.error(f'Erreur lors de la mise à jour du cache anti-cheat: {e}')
responses = _call_algoliasearch(search_name)
if responses is None:
return results
for hit in responses.model_dump().get('hits'):
id = hit.get('object_id')
name:str = hit.get('name')
@@ -172,27 +65,12 @@ def searhProtonDb(search_name:str):
summmary = _call_summary(id)
if (summmary != None) :
tier = summmary.get('tier')
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 = {
results.append({
'id':id,
'name' : name,
'tier' : 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 ''))
})
logging.info(f'Trouvé {name}({id}) : {tier}')
except Exception as e:
logging.error(f'Erreur lors du traitement du jeu {name} (ID: {id}) : {e}')
else:

View File

@@ -1,8 +1,6 @@
import locale
import logging
import threading
import os
from logging.handlers import RotatingFileHandler
from webapp import webapp
from discordbot import bot
@@ -25,40 +23,12 @@ def start_twitch_bot():
twitchBot.begin()
if __name__ == '__main__':
# Config logs (console + fichier avec rotation)
os.makedirs('logs', exist_ok=True)
log_formatter = logging.Formatter('%(asctime)s %(levelname)s [%(threadName)s] %(name)s: %(message)s')
handlers = []
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(log_formatter)
handlers.append(stream_handler)
file_handler = RotatingFileHandler('logs/app.log', maxBytes=5*1024*1024, backupCount=5, encoding='utf-8')
file_handler.setFormatter(log_formatter)
handlers.append(file_handler)
logging.basicConfig(level=logging.INFO, handlers=handlers)
# Calmer les logs verbeux de certaines libs si besoin
logging.getLogger('werkzeug').setLevel(logging.WARNING)
logging.getLogger('discord').setLevel(logging.WARNING)
# Hook exceptions non-capturées (threads inclus)
def _log_uncaught(exc_type, exc, tb):
logging.exception('Exception non capturée', exc_info=(exc_type, exc, tb))
import sys
sys.excepthook = _log_uncaught
if hasattr(threading, 'excepthook'):
def _thread_excepthook(args):
logging.exception(f"Exception dans le thread {args.thread.name}", exc_info=(args.exc_type, args.exc_value, args.exc_traceback))
threading.excepthook = _thread_excepthook
locale.setlocale(locale.LC_TIME, 'fr_FR.UTF-8')
jobs = []
jobs.append(threading.Thread(target=start_discord_bot, name='discord-bot'))
jobs.append(threading.Thread(target=start_server, name='web-server'))
jobs.append(threading.Thread(target=start_twitch_bot, name='twitch-bot'))
jobs.append(threading.Thread(target=start_discord_bot))
jobs.append(threading.Thread(target=start_server))
jobs.append(threading.Thread(target=start_twitch_bot))
for job in jobs:
job.start()
for job in jobs:
job.join()
for job in jobs: job.start()
for job in jobs: job.join()

View File

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

View File

@@ -1,3 +1,4 @@
import asyncio
import logging
from twitchAPI.twitch import Twitch
@@ -36,14 +37,24 @@ async def _notifyAlert(alert : LiveAlert, stream : Stream):
async def _sendMessage(channel : int, message : str) :
logger.info(f'Envoi de notification : {message}')
await bot.get_channel(channel).send(message)
logger.info(f'Notification envoyé')
try:
await asyncio.wait_for(bot.get_channel(channel).send(message), timeout=30.0)
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] :
streams : list[Stream] = []
logger.info(f'Recherche de streams pour : {alerts}')
async for stream in twitch.get_streams(user_login = [alert.login for alert in alerts]):
try:
async for stream in asyncio.wait_for(twitch.get_streams(user_login = [alert.login for alert in alerts]), timeout=30.0):
streams.append(stream)
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

View File

@@ -2,4 +2,4 @@ from flask import Flask
webapp = Flask(__name__)
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth

View File

@@ -6,37 +6,17 @@ from discordbot import bot
@webapp.route("/configurations")
def openConfigurations():
return render_template("configurations.html", configuration = ConfigurationHelper(), channels = bot.getAllTextChannel(), roles = bot.getAllRoles())
return render_template("configurations.html", configuration = ConfigurationHelper(), channels = bot.getAllTextChannel())
@webapp.route("/configurations/update", methods=['POST'])
def updateConfiguration():
checkboxes = {
'humble_bundle_enable': 'humble_bundle_channel',
'proton_db_enable_enable': 'proton_db_api_id',
'moderation_enable': 'moderation_staff_role_ids',
'moderation_ban_enable': 'moderation_staff_role_ids',
'moderation_kick_enable': 'moderation_staff_role_ids',
'welcome_enable': 'welcome_channel_id',
'leave_enable': 'leave_channel_id'
}
staff_roles = request.form.getlist('moderation_staff_role_ids')
if staff_roles:
ConfigurationHelper().createOrUpdate('moderation_staff_role_ids', ','.join(staff_roles))
else:
ConfigurationHelper().createOrUpdate('moderation_staff_role_ids', '')
for key in request.form:
if key == 'moderation_staff_role_ids':
continue
value = request.form.get(key)
if value and value.strip():
ConfigurationHelper().createOrUpdate(key, value)
for checkbox, reference_field in checkboxes.items():
if request.form.get(reference_field) is not None and request.form.get(checkbox) is None:
ConfigurationHelper().createOrUpdate(checkbox, False)
for key in request.form :
ConfigurationHelper().createOrUpdate(key, request.form.get(key))
# Je fais ça car HTML n'envoie pas le paramètre de checkbox quand il est décoché
if (request.form.get("humble_bundle_channel") != None and request.form.get("humble_bundle_enable") == None) :
ConfigurationHelper().createOrUpdate('humble_bundle_enable', False)
if (request.form.get("proton_db_api_id") != None and request.form.get("proton_db_enable_enable") == None) :
ConfigurationHelper().createOrUpdate('proton_db_enable_enable', False)
db.session.commit()
return redirect(request.referrer)

View File

@@ -1,30 +0,0 @@
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'))

603
webapp/static/css/mvp.css Normal file
View File

@@ -0,0 +1,603 @@
/* MVP.css v1.17.2 - https://github.com/andybrewer/mvp */
:root {
--active-brightness: 0.85;
--border-radius: 5px;
--box-shadow: 2px 2px 10px;
--color-accent: #118bee15;
--color-bg: #fff;
--color-bg-secondary: #e9e9e9;
--color-link: #118bee;
--color-secondary: #920de9;
--color-secondary-accent: #920de90b;
--color-shadow: #f4f4f4;
--color-table: #118bee;
--color-text: #000;
--color-text-secondary: #999;
--color-scrollbar: #cacae8;
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
--hover-brightness: 1.2;
--justify-important: center;
--justify-normal: left;
--line-height: 1.5;
--width-card: 285px;
--width-card-medium: 460px;
--width-card-wide: 800px;
--width-content: 1080px;
}
@media (prefers-color-scheme: dark) {
:root[color-mode="user"] {
--color-accent: #0097fc4f;
--color-bg: #333;
--color-bg-secondary: #555;
--color-link: #0097fc;
--color-secondary: #e20de9;
--color-secondary-accent: #e20de94f;
--color-shadow: #bbbbbb20;
--color-table: #0097fc;
--color-text: #f7f7f7;
--color-text-secondary: #aaa;
}
}
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
/* Layout */
article aside {
background: var(--color-secondary-accent);
border-left: 4px solid var(--color-secondary);
padding: 0.01rem 0.8rem;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family);
line-height: var(--line-height);
margin: 0;
overflow-x: hidden;
padding: 0;
}
footer,
header,
main {
margin: 0 auto;
max-width: var(--width-content);
/* padding: 3rem 1rem; */
padding: 1rem 1rem;
}
hr {
background-color: var(--color-bg-secondary);
border: none;
height: 1px;
margin: 4rem 0;
width: 100%;
}
section {
display: flex;
flex-wrap: wrap;
justify-content: var(--justify-important);
}
section img,
article img {
max-width: 100%;
}
section pre {
overflow: auto;
}
section aside {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
margin: 1rem;
padding: 1.25rem;
width: var(--width-card);
}
section aside:hover {
box-shadow: var(--box-shadow) var(--color-bg-secondary);
}
[hidden] {
display: none;
}
/* Headers */
article header,
div header,
main header {
padding-top: 0;
}
header {
text-align: var(--justify-important);
}
header a b,
header a em,
header a i,
header a strong {
margin-left: 0.5rem;
margin-right: 0.5rem;
}
/* header nav img {
margin: 1rem 0;
} */
section header {
padding-top: 0;
width: 100%;
}
/* Nav */
nav {
align-items: center;
display: flex;
font-weight: bold;
justify-content: space-between;
/* margin-bottom: 7rem; */
}
nav ul {
list-style: none;
padding: 0;
}
nav ul li {
display: inline-block;
margin: 0 0.5rem;
position: relative;
text-align: left;
}
/* Nav Dropdown */
nav ul li:hover ul {
display: block;
}
nav ul li ul {
background: var(--color-bg);
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
display: none;
height: auto;
left: -2px;
padding: 0.5rem 1rem;
position: absolute;
top: 1.7rem;
white-space: nowrap;
width: auto;
z-index: 1;
}
nav ul li ul::before {
/* fill gap above to make mousing over them easier */
content: "";
position: absolute;
left: 0;
right: 0;
top: -0.5rem;
height: 0.5rem;
}
nav ul li ul li,
nav ul li ul li a {
display: block;
}
/* Nav for Mobile */
@media (max-width: 768px) {
nav {
flex-wrap: wrap;
}
nav ul li {
width: calc(100% - 1em);
}
nav ul li ul {
border: none;
box-shadow: none;
display: block;
position: static;
}
}
/* Typography */
code,
samp {
background-color: var(--color-accent);
border-radius: var(--border-radius);
color: var(--color-text);
display: inline-block;
margin: 0 0.1rem;
padding: 0 0.5rem;
}
details {
margin: 1.3rem 0;
}
details summary {
font-weight: bold;
cursor: pointer;
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: var(--line-height);
text-wrap: balance;
}
mark {
padding: 0.1rem;
}
ol li,
ul li {
padding: 0.2rem 0;
}
p {
margin: 0.75rem 0;
padding: 0;
width: 100%;
}
pre {
margin: 1rem 0;
max-width: var(--width-card-wide);
padding: 1rem 0;
}
pre code,
pre samp {
display: block;
max-width: var(--width-card-wide);
padding: 0.5rem 2rem;
white-space: pre-wrap;
}
small {
color: var(--color-text-secondary);
}
sup {
background-color: var(--color-secondary);
border-radius: var(--border-radius);
color: var(--color-bg);
font-size: xx-small;
font-weight: bold;
margin: 0.2rem;
padding: 0.2rem 0.3rem;
position: relative;
top: -2px;
}
/* Links */
a {
color: var(--color-link);
display: inline-block;
font-weight: bold;
text-decoration: underline;
}
a:hover {
filter: brightness(var(--hover-brightness));
}
a:active {
filter: brightness(var(--active-brightness));
}
a b,
a em,
a i,
a strong,
button,
input[type="submit"] {
border-radius: var(--border-radius);
display: inline-block;
font-size: medium;
font-weight: bold;
line-height: var(--line-height);
margin: 0.5rem 0;
padding: 1rem 2rem;
}
button,
input[type="submit"] {
font-family: var(--font-family);
}
button:hover,
input[type="submit"]:hover {
cursor: pointer;
filter: brightness(var(--hover-brightness));
}
button:active,
input[type="submit"]:active {
filter: brightness(var(--active-brightness));
}
a b,
a strong,
button,
input[type="submit"] {
background-color: var(--color-link);
border: 2px solid var(--color-link);
color: var(--color-bg);
}
a em,
a i {
border: 2px solid var(--color-link);
border-radius: var(--border-radius);
color: var(--color-link);
display: inline-block;
padding: 1rem 2rem;
}
article aside a {
color: var(--color-secondary);
}
/* Images */
figure {
margin: 0;
padding: 0;
}
figure img {
max-width: 100%;
}
figure figcaption {
color: var(--color-text-secondary);
}
/* Forms */
button:disabled,
input:disabled {
background: var(--color-bg-secondary);
border-color: var(--color-bg-secondary);
color: var(--color-text-secondary);
cursor: not-allowed;
}
button[disabled]:hover,
input[type="submit"][disabled]:hover {
filter: none;
}
form {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
display: block;
max-width: var(--width-card-wide);
min-width: var(--width-card);
padding: 1.5rem;
text-align: var(--justify-normal);
}
form header {
margin: 1.5rem 0;
padding: 1.5rem 0;
}
input,
label,
select,
textarea {
display: block;
font-size: inherit;
max-width: var(--width-card-wide);
}
input[type="checkbox"],
input[type="radio"] {
display: inline-block;
}
input[type="checkbox"]+label,
input[type="radio"]+label {
display: inline-block;
font-weight: normal;
position: relative;
top: 1px;
}
input[type="range"] {
padding: 0.4rem 0;
}
input,
select,
textarea {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
margin-bottom: 1rem;
padding: 0.4rem 0.8rem;
}
input[type="text"],
input[type="password"],
input[type="email"],
textarea {
width: calc(100% - 1.6rem);
}
input[readonly],
textarea[readonly] {
background-color: var(--color-bg-secondary);
}
label {
font-weight: bold;
margin-bottom: 0.2rem;
}
/* Popups */
dialog {
max-width: 90%;
max-height: 85dvh;
margin: auto;
padding-block: 0;
padding-inline: 20px;
border: 1px solid var(--color-bg-secondary);
border-radius: 0.5rem;
overscroll-behavior: contain;
scroll-behavior: smooth;
scrollbar-width: none;
/* Hide scrollbar for Firefox */
-ms-overflow-style: none;
/* Hide scrollbar for IE and Edge */
scrollbar-color: transparent transparent;
animation: bottom-to-top 0.25s ease-in-out forwards;
}
dialog::-webkit-scrollbar {
width: 0;
display: none;
}
dialog::-webkit-scrollbar-track {
background: transparent;
}
dialog::-webkit-scrollbar-thumb {
background-color: transparent;
}
@media (min-width: 650px) {
dialog {
max-width: 39rem;
}
}
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
@keyframes bottom-to-top {
0% {
opacity: 0;
transform: translateY(10%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
dialog hr {
margin-block: 1rem;
}
/* Tables */
table {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
border-spacing: 0;
display: inline-block;
max-width: 100%;
overflow-x: auto;
padding: 0;
white-space: nowrap;
}
table td,
table th,
table tr {
padding: 0.4rem 0.8rem;
text-align: var(--justify-important);
}
table thead {
background-color: var(--color-table);
border-collapse: collapse;
border-radius: var(--border-radius);
color: var(--color-bg);
margin: 0;
padding: 0;
}
table thead tr:first-child th:first-child {
border-top-left-radius: var(--border-radius);
}
table thead tr:first-child th:last-child {
border-top-right-radius: var(--border-radius);
}
table thead th:first-child,
table tr td:first-child {
text-align: var(--justify-normal);
}
table tr:nth-child(even) {
background-color: var(--color-accent);
}
/* Quotes */
blockquote {
display: block;
font-size: x-large;
line-height: var(--line-height);
margin: 1rem auto;
max-width: var(--width-card-medium);
padding: 1.5rem 1rem;
text-align: var(--justify-important);
}
blockquote footer {
color: var(--color-text-secondary);
display: block;
font-size: small;
line-height: var(--line-height);
padding: 1.5rem 0;
}
/* Scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-scrollbar) transparent;
}
*::-webkit-scrollbar {
width: 5px;
height: 5px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar);
border-radius: 10px;
}

View File

@@ -0,0 +1,19 @@
header nav img {
border-radius: 50%;
}
table th,
table td {
text-align: left;
vertical-align: top;
overflow: hidden;
white-space: normal;
}
table.live-alert tr td:last-child {
white-space: nowrap;
}
a.icon {
text-decoration: none;
}

View File

@@ -1,101 +1,55 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Commandes</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Gérez les commandes personnalisées du bot. Ces commandes peuvent être activées sur Discord et/ou Twitch.
</p>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
<div class="overflow-x-auto">
<table class="w-full">
<h1>Commandes de Mamie</h1>
<p>Gérez les commandes personnalisées du bot. Ces commandes peuvent être activées sur Discord et/ou Twitch selon vos besoins.</p>
<table>
<thead>
<tr class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Commande</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Réponse</th>
<th class="px-4 py-3 text-center text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Discord</th>
<th class="px-4 py-3 text-center text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Twitch</th>
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Actions</th>
<tr>
<th>Commande</th>
<th>Réponse</th>
<th>Discord</th>
<th>Twitch</th>
<th>Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<tbody>
{% for commande in commandes %}
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
<td class="px-4 py-3">
<code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded text-xs font-mono">{{ commande.trigger }}</code>
</td>
<td class="px-4 py-3 text-slate-600 dark:text-slate-400 text-sm max-w-xs">
<div class="line-clamp-2">{{ commande.response }}</div>
</td>
<td class="px-4 py-3 text-center">
<a href="{{ url_for('toggle_discord_commande', commande_id = commande.id) }}" class="inline-flex" title="{{ 'Désactiver' if commande.discord_enable else 'Activer' }}">
{% if commande.discord_enable %}
<span class="w-5 h-5 text-green-600 dark:text-green-500"></span>
{% else %}
<span class="w-5 h-5 text-slate-400"></span>
{% endif %}
</a>
</td>
<td class="px-4 py-3 text-center">
<a href="{{ url_for('toggle_twitch_commande', commande_id = commande.id) }}" class="inline-flex" title="{{ 'Désactiver' if commande.twitch_enable else 'Activer' }}">
{% if commande.twitch_enable %}
<span class="w-5 h-5 text-green-600 dark:text-green-500"></span>
{% else %}
<span class="w-5 h-5 text-slate-400"></span>
{% endif %}
</a>
</td>
<td class="px-4 py-3 text-right">
<a href="{{ url_for('delete_commande', commande_id = commande.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette commande ?')" class="text-sm text-slate-500 hover:text-red-600 dark:hover:text-red-400 transition-colors">
Supprimer
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="px-4 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
Aucune commande configurée
<td>{{ commande.trigger }}</td>
<td>{{ commande.response }}</td>
<td>
<a href="{{ url_for('toggle_discord_commande', commande_id = commande.id) }}" class="icon">
{{ '✅' if commande.discord_enable else '❌' }}
</a>
</td>
<td>
<a href="{{ url_for('toggle_twitch_commande', commande_id = commande.id) }}" class="icon">
{{ '✅' if commande.twitch_enable else '❌' }}
</a>
</td>
<td>
<a href="{{ url_for('delete_commande', commande_id = commande.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette commande ?')">Supprimer</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</table>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-5">
<h2 class="text-lg font-medium text-slate-800 dark:text-white mb-5">Ajouter une commande</h2>
<form action="{{ url_for('add_commande') }}" method="POST" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<h2>Ajouter une commande</h2>
<form action="{{ url_for('add_commande') }}" method="POST">
<label for="trigger">Commande</label>
<input name="trigger" type="text" />
<label for="response">Réponse</label>
<textarea name="response" rows="5" cols="50"></textarea>
<div>
<label for="trigger" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Commande</label>
<input type="text" name="trigger" id="trigger" placeholder="!macommande" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
<label for="discord_enable">Discord</label>
<input name="discord_enable" type="checkbox" checked />
</div>
<div class="flex items-end gap-6">
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="discord_enable" checked class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500 focus:ring-2">
<span class="text-sm text-slate-700 dark:text-slate-300">Discord</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="twitch_enable" class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500 focus:ring-2">
<span class="text-sm text-slate-700 dark:text-slate-300">Twitch</span>
</label>
</div>
</div>
<div>
<label for="response" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Réponse</label>
<textarea name="response" id="response" rows="4" placeholder="Le message que le bot enverra..." class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all resize-none"></textarea>
<label for="twitch_enable">Twitch</label>
<input name="twitch_enable" type="checkbox" unchecked />
</div>
<div class="flex justify-end">
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
Ajouter
</button>
</div>
</form>
</div>
<input type="Submit" value="Ajouter">
</form>
{% endblock %}

View File

@@ -1,251 +1,58 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Configurations</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Paramètres Discord, Twitch et Humble Bundle.
<h1>Configuration de Mamie</h1>
<p>Configurez les tokens Discord, les notifications Humble Bundle et l'API ProtonDB pour la commande !protondb.</p>
<h2>API Discord</h2>
<form action="{{ url_for('updateConfiguration') }}" method="POST">
<label for="discord_token">API Discord (cachée)</label>
<input name="discord_token" type="password" />
<input type="Submit" value="Définir">
<p>Nécessite un redémarrage</p>
</form>
<h2>API Twitch</h2>
<form action="{{ url_for('updateConfiguration') }}" method="POST">
<label for="twitch_client_id">Client ID</label>
<input name="twitch_client_id" type="text" value="{{ configuration.getValue('twitch_client_id') }}" />
<label for="twitch_client_secret">Client Secret</label>
<input name="twitch_client_secret" type="text" value="{{ configuration.getValue('twitch_client_secret') }}" />
<label for="twitch_channel">Chaîne à rejoindre</label>
<input name="twitch_channel" type="text" value="{{ configuration.getValue('twitch_channel') }}"
placeholder="#machinTruc" />
<input type="Submit" value="Définir">
<p>
<a href="{{ url_for('twitchConfigurationHelp') }}">Aide</a>
</p>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 mb-6 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Discord</h2>
</div>
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="p-5 space-y-6">
<div>
<label for="discord_token" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Token Discord</label>
<input type="password" name="discord_token" id="discord_token" placeholder="Votre token Discord (caché)" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">Nécessite un redémarrage après modification</p>
</div>
<div class="pt-4 border-t border-slate-200 dark:border-slate-700">
<h3 class="text-sm font-medium text-slate-800 dark:text-white mb-4">Messages de bienvenue</h3>
<label class="flex items-center gap-3 cursor-pointer mb-4">
<input type="checkbox" name="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
<span class="text-sm text-slate-700 dark:text-slate-300">Activer le message de bienvenue</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="welcome_channel_id" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Canal de bienvenue</label>
<select name="welcome_channel_id" id="welcome_channel_id" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('welcome_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label for="welcome_message" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Message personnalisé</label>
<textarea name="welcome_message" id="welcome_message" rows="2" placeholder="Bienvenue {member.mention} sur le serveur !" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all resize-none">{{ configuration.getValue('welcome_message') }}</textarea>
</div>
<div class="mt-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg p-3">
<p class="text-xs font-medium text-slate-600 dark:text-slate-400 mb-2">Variables :</p>
<div class="flex flex-wrap gap-2 text-xs">
<code class="px-1.5 py-0.5 bg-slate-200 dark:bg-slate-600 rounded">{member.mention}</code>
<code class="px-1.5 py-0.5 bg-slate-200 dark:bg-slate-600 rounded">{member.name}</code>
<code class="px-1.5 py-0.5 bg-slate-200 dark:bg-slate-600 rounded">{server.name}</code>
<code class="px-1.5 py-0.5 bg-slate-200 dark:bg-slate-600 rounded">{server.member_count}</code>
</div>
</div>
</div>
<div class="pt-4 border-t border-slate-200 dark:border-slate-700">
<h3 class="text-sm font-medium text-slate-800 dark:text-white mb-4">Messages de départ</h3>
<label class="flex items-center gap-3 cursor-pointer mb-4">
<input type="checkbox" name="leave_enable" {% if configuration.getValue('leave_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
<span class="text-sm text-slate-700 dark:text-slate-300">Activer le message de départ</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="leave_channel_id" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Canal de départ</label>
<select name="leave_channel_id" id="leave_channel_id" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('leave_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label for="leave_message" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Message personnalisé</label>
<textarea name="leave_message" id="leave_message" rows="2" placeholder="{member.mention} a quitté le serveur." class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all resize-none">{{ configuration.getValue('leave_message') }}</textarea>
</div>
</div>
<div class="pt-4 border-t border-slate-200 dark:border-slate-700">
<h3 class="text-sm font-medium text-slate-800 dark:text-white mb-4">Modération</h3>
<div class="space-y-3 mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
<span class="text-sm text-slate-700 dark:text-slate-300">Activer les commandes d'avertissement</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
<span class="text-sm text-slate-700 dark:text-slate-300">Activer les commandes de bannissement</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
<span class="text-sm text-slate-700 dark:text-slate-300">Activer la commande d'expulsion</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="moderation_log_channel_id" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Canal de logs</label>
<select name="moderation_log_channel_id" id="moderation_log_channel_id" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('moderation_log_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="moderation_embed_delete_delay" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Délai suppression (sec)</label>
<input type="number" name="moderation_embed_delete_delay" id="moderation_embed_delete_delay" value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" min="0" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">Rôles Staff autorisés</label>
{% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %}
{% if roles|length > 1 %}
<div class="flex flex-wrap gap-2 mb-3">
{% for guild_data in roles %}
<button type="button" onclick="openTab(event, 'guild-{{ guild_data.guild_id }}')" class="tab-button px-3 py-1.5 text-sm rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors {% if loop.first %}active bg-slate-200 dark:bg-slate-600{% 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 %}hidden{% endif %}">
<div class="max-h-48 overflow-y-auto bg-slate-50 dark:bg-slate-700/50 rounded-lg p-3 space-y-1">
{% for role in guild_data.roles %}
<label class="flex items-center gap-3 cursor-pointer p-2 rounded hover:bg-slate-100 dark:hover:bg-slate-600/50 transition-colors">
<input type="checkbox" name="moderation_staff_role_ids" value="{{ role.id }}" {% if role.id|string in selected_roles %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
{% if role.color.value != 0 %}
<span style="color:#{{ '%06x' % role.color.value }}"></span>
{% else %}
<span class="text-slate-400"></span>
{% endif %}
<span class="text-sm text-slate-700 dark:text-slate-300">{{ role.name }}</span>
</label>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
<div class="pt-4 border-t border-slate-200 dark:border-slate-700">
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
Enregistrer
</button>
</div>
</form>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 mb-6 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">API Twitch</h2>
</div>
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="p-5 space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="twitch_client_id" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Client ID</label>
<input type="text" name="twitch_client_id" id="twitch_client_id" value="{{ configuration.getValue('twitch_client_id') }}" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
<div>
<label for="twitch_client_secret" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Client Secret</label>
<input type="text" name="twitch_client_secret" id="twitch_client_secret" value="{{ configuration.getValue('twitch_client_secret') }}" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
</div>
<div>
<label for="twitch_channel" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Chaîne à rejoindre</label>
<input type="text" name="twitch_channel" id="twitch_channel" value="{{ configuration.getValue('twitch_channel') }}" placeholder="#machinTruc" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
<div>
<a href="{{ url_for('twitchRequestToken') }}" class="text-sm text-slate-600 dark:text-slate-400 hover:underline">
Obtenir token et refresh token
</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="twitch_access_token" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Access Token</label>
<input type="text" name="twitch_access_token" id="twitch_access_token" value="{{ configuration.getValue('twitch_access_token') }}" readonly class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 font-mono">
</div>
<div>
<label for="twitch_refresh_token" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Refresh Token</label>
<input type="text" name="twitch_refresh_token" id="twitch_refresh_token" value="{{ configuration.getValue('twitch_refresh_token') }}" readonly class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 font-mono">
</div>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400">Nécessite un redémarrage après l'obtention des tokens</p>
{% endif %}
<div class="flex items-center gap-4">
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
Enregistrer
</button>
<a href="{{ url_for('twitchConfigurationHelp') }}" class="text-sm text-slate-600 dark:text-slate-400 hover:underline">Besoin d'aide ?</a>
</div>
</form>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Humble Bundle</h2>
</div>
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="p-5 space-y-6">
<p class="text-sm text-slate-600 dark:text-slate-400">
Activez les notifications pour recevoir automatiquement les nouveaux packs sur Discord.
<p>
<a href="{{ url_for('twitchRequestToken') }}">Obtenir token et refresh token</a>
</p>
<label for="twitch_access_token">Access Token</label>
<input name="twitch_access_token" type="text" value="{{ configuration.getValue('twitch_access_token') }}"
readonly="readonly" />
<label for="twitch_refresh_token">Refresh Token</label>
<input name="twitch_refresh_token" type="text" value="{{ configuration.getValue('twitch_refresh_token') }}"
readonly="readonly" />
<p>Nécessite un redémarrage après l'obtention des Tokens.</p>
{% endif %}
</form>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
<span class="text-sm text-slate-700 dark:text-slate-300">Activer les notifications Humble Bundle</span>
</label>
<div>
<label for="humble_bundle_channel" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Canal de notification</label>
<select name="humble_bundle_channel" id="humble_bundle_channel" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
<h2>Humble Bundle</h2>
<form action="{{ url_for('updateConfiguration') }}" method="POST">
<label for="humble_bundle_enable">Activer</label>
<input type="checkbox" name="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}
checked="checked" {% endif %}>
<label>Activer les notifications Humble Bundle</label>
<label for="humble_bundle_channel">Canal de notification des packs Humble Bundle</label>
<select name="humble_bundle_channel">
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('humble_bundle_channel') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
<option value="{{channel.id}}" {% if configuration.getIntValue('humble_bundle_channel')==channel.id %}
selected="selected" {% endif %}>
{{channel.name}}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
Enregistrer
</button>
</form>
</div>
<script>
function openTab(evt, tabName) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.tab-button').forEach(el => {
el.classList.remove('active', 'bg-slate-200', 'dark:bg-slate-600');
el.classList.add('bg-slate-100', 'dark:bg-slate-700');
});
document.getElementById(tabName).classList.remove('hidden');
evt.currentTarget.classList.add('active', 'bg-slate-200', 'dark:bg-slate-600');
evt.currentTarget.classList.remove('bg-slate-100', 'dark:bg-slate-700');
}
</script>
<input type="Submit" value="Définir">
</form>
{% endblock %}

View File

@@ -1,44 +1,29 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Humeurs</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Statuts Discord qui changeront automatiquement toutes les 10 minutes.
</p>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
{% if humeurs %}
<ul class="divide-y divide-slate-200 dark:divide-slate-700">
<h1>Humeurs de Mamie</h1>
<p>Définissez les statuts Discord qui changeront automatiquement toutes les 10 minutes pour donner de la personnalité à votre bot.</p>
<table>
<thead>
<tr>
<th>Texte</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for humeur in humeurs %}
<li class="flex items-center justify-between px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors group">
<span class="text-sm text-slate-700 dark:text-slate-300">{{ humeur.text }}</span>
<a href="{{ url_for('delHumeur', id = humeur.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette humeur ?')" class="p-1.5 text-slate-400 hover:text-red-500 dark:hover:text-red-400 rounded opacity-0 group-hover:opacity-100 transition-all" title="Supprimer">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
</a>
</li>
<tr>
<td>{{humeur.text}}</td>
<td><a href="{{ url_for('delHumeur', id = humeur.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette humeur ?')">Supprimer</a></td>
</tr>
{% endfor %}
</ul>
{% else %}
<div class="px-4 py-8 text-center">
<p class="text-sm text-slate-500 dark:text-slate-400">Aucune humeur configurée</p>
</div>
{% endif %}
</div>
</tbody>
</table>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-5">
<h2 class="text-lg font-medium text-slate-800 dark:text-white mb-4">Ajouter une humeur</h2>
<form action="{{ url_for('addHumeur') }}" method="POST">
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex-1">
<input type="text" name="text" id="text" placeholder="Joue à un super jeu..." class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
Ajouter
</button>
</div>
</form>
</div>
<h2>Ajouter une humeur</h2>
<form action="{{ url_for('addHumeur') }}" method="POST">
<label for="text">Texte</label>
<input name="text" type="text" />
<input type="Submit" value="Ajouter">
</form>
{% endblock %}

View File

@@ -1,117 +1,7 @@
{% extends "template.html" %}
{% block content %}
<div class="text-center py-10">
<h1 class="text-3xl sm:text-4xl font-bold text-slate-800 dark:text-white mb-3">
Panneau d'administration
</h1>
<p class="text-base text-slate-600 dark:text-slate-400 max-w-xl mx-auto">
Gérez les fonctionnalités de votre bot Discord et Twitch depuis cette interface.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-10">
<a href="/live-alert" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
</div>
<div>
<h3 class="font-medium text-slate-800 dark:text-white">Alertes Live</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">Notifications Twitch</p>
</div>
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</div>
</a>
<a href="/commandes" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
</div>
<div>
<h3 class="font-medium text-slate-800 dark:text-white">Commandes</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">Commandes personnalisées</p>
</div>
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</div>
</a>
<a href="/humeurs" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
<div>
<h3 class="font-medium text-slate-800 dark:text-white">Humeurs</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">Statuts Discord rotatifs</p>
</div>
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</div>
</a>
<a href="/moderation" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
</div>
<div>
<h3 class="font-medium text-slate-800 dark:text-white">Modération</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">Historique et actions</p>
</div>
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</div>
</a>
<a href="/protondb" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
</div>
<div>
<h3 class="font-medium text-slate-800 dark:text-white">ProtonDB</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">Compatibilité Linux</p>
</div>
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</div>
</a>
<a href="/configurations" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
</div>
<div>
<h3 class="font-medium text-slate-800 dark:text-white">Configurations</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">Paramètres du bot</p>
</div>
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</div>
</a>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 border border-slate-200 dark:border-slate-700">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
<div>
<h3 class="font-medium text-slate-800 dark:text-white mb-2">À propos</h3>
<p class="text-sm text-slate-600 dark:text-slate-400 mb-4">
Mamie Henriette est un bot open source pour Discord et Twitch, développé par la communauté.
Cette interface vous permet de configurer et gérer toutes les fonctionnalités.
</p>
<div class="flex flex-wrap gap-3">
<a href="https://github.com/skylanix/MamieHenriette" target="_blank" class="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-800 dark:bg-slate-700 text-white rounded text-sm hover:bg-slate-700 dark:hover:bg-slate-600 transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path></svg>
GitHub
</a>
<a href="https://discord.com/invite/UwAPqMJnx3" target="_blank" class="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-600 rounded text-sm hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"></path></svg>
Discord
</a>
</div>
</div>
</div>
</div>
<h1>Bienvenue sur l'interface d'administration de Mamie.</h1>
<p>Nous devons définir ce que nous souhaitons afficher sur la page d'accueil. Peut-être l'historique des dernières
modifications ? de la modération ?</p>
{% endblock %}

View File

@@ -1,135 +1,76 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Alertes Live</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Chaînes Twitch surveillées. Vérification toutes les 5 minutes, maximum 100 chaînes.
</p>
</div>
<h1>Alerte Live</h1>
<p>
Liste des chaines surveillées pour les alertes de live twitch.
Le bot vérifie toutes les 5 minutes qui est en live dans la liste en dessous.
Le bot enregistre le status de stream toutes les 5 minutes, quand le status pass de "hors-ligne" à "en ligne" alors
le bot le notifiera sur discord.
Ne peu surveiller qu'au maximum 100 chaines.
</p>
{% if not alert %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Alertes configurées</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<h2>Alertes</h2>
<table class="live-alert">
<thead>
<tr class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Chaîne</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Canal Discord</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Message</th>
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Actions</th>
<tr>
<th>Chaine</th>
<th>Canal</th>
<th>Message</th>
<th>#</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<tbody>
{% for alert in alerts %}
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
<td class="px-4 py-3">
<a href="https://www.twitch.tv/{{ alert.login }}" target="_blank" class="text-sm font-medium text-slate-700 dark:text-slate-300 hover:underline">{{ alert.login }}</a>
</td>
<td class="px-4 py-3">
<span class="text-sm text-slate-600 dark:text-slate-400">#{{ alert.notify_channel_name }}</span>
</td>
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400 max-w-xs">
<div class="line-clamp-2">{{ alert.message }}</div>
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('toggleLiveAlert', id = alert.id) }}" class="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors" title="{{ 'Désactiver' if alert.enable else 'Activer' }}">
{% if alert.enable %}
<span class="text-green-600 dark:text-green-500">Actif</span>
{% else %}
<span class="text-slate-400">Inactif</span>
{% endif %}
</a>
<a href="{{ url_for('openEditLiveAlert', id = alert.id) }}" class="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors">
Modifier
</a>
<a href="{{ url_for('delLiveAlert', id = alert.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette alerte ?')" class="text-sm text-slate-500 hover:text-red-600 dark:hover:text-red-400 transition-colors">
Supprimer
</a>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
Aucune alerte configurée
<td>{{alert.login}}</td>
<td>{{alert.notify_channel_name}}</td>
<td>{{alert.message}}</td>
<td>
<a href="{{ url_for('toggleLiveAlert', id = alert.id) }}" class="icon">{{ '✅' if alert.enable else '❌' }}</a>
<a href="{{ url_for('openEditLiveAlert', id = alert.id) }}" class="icon"></a>
<a href="{{ url_for('delLiveAlert', id = alert.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette alerte ?')" class="icon">🗑</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</table>
{% endif %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-5">
<h2 class="text-lg font-medium text-slate-800 dark:text-white mb-5">
{% if alert %}Modifier l'alerte{% else %}Ajouter une alerte{% endif %}
</h2>
<form action="{{ url_for('submitEditLiveAlert', id = alert.id) if alert else url_for('addLiveAlert') }}" method="POST" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="login" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Chaîne Twitch</label>
<input type="text" name="login" id="login" maxlength="32" required value="{{ alert.login if alert else '' }}" placeholder="chainesteve" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">Le login de la chaîne, ex: chainesteve</p>
</div>
<div>
<label for="notify_channel" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Canal de Notification</label>
<select name="notify_channel" id="notify_channel" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
<h2>{{ 'Editer une alerte' if alert else 'Ajouter une alerte de Live' }}</h2>
<form action="{{ url_for('submitEditLiveAlert', id = alert.id) if alert else url_for('addLiveAlert') }}" method="POST">
<label for="login">Chaine</label>
<input name="login" type="text" maxlength="32" required="required" value="{{alert.login if alert}}"/>
<label for="notify_channel">Canal de Notification</label>
<select name="notify_channel">
{% for channel in channels %}
<option value="{{ channel.id }}" {% if alert and alert.notify_channel == channel.id %}selected{% endif %}>{{ channel.name }}</option>
<option value="{{channel.id}}"{% if alert and alert.notify_channel == channel.id %}
selected="selected" {% endif %}>{{channel.name}}</option>
{% endfor %}
</select>
</div>
</div>
<label for="message">Message</label>
<textarea name="message" rows="5" cols="50" required="required">{{alert.message if alert}}</textarea>
<input type="Submit" value="Ajouter">
<p>
La chaine est le login de la chaine, par exemple <strong>chainesteve</strong> pour <strong>https://www.twitch.tv/chainesteve</strong>.
</p>
<p>
Pour le message vous avez acces à ces variables :
<ul>
<li>{0.user_login} : pour le lien vers la chaine</li>
<li>{0.user_name} : à priviligier pour le text</li>
<li>{0.game_name}</li>
<li>{0.title}</li>
<li>{0.language}</li>
</ul>
Le message est au format <a href="https://commonmark.org/" target="_blank">common-mark</a> dans la limite de ce que
support discord.
Pour mettre un lien vers la chaine : [description](https://www.twitch.tv/{0.user_login})
</p>
</form>
<div>
<label for="message" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Message de notification</label>
<textarea name="message" id="message" rows="4" required placeholder="🔴 **{0.user_name}** est en live !" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all resize-none">{{ alert.message if alert else '' }}</textarea>
</div>
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4">
<p class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">Variables disponibles :</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
<div class="flex items-center gap-2">
<code class="px-2 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs font-mono">{0.user_login}</code>
<span class="text-slate-600 dark:text-slate-400">Lien vers la chaîne</span>
</div>
<div class="flex items-center gap-2">
<code class="px-2 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs font-mono">{0.user_name}</code>
<span class="text-slate-600 dark:text-slate-400">Nom du streamer</span>
</div>
<div class="flex items-center gap-2">
<code class="px-2 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs font-mono">{0.game_name}</code>
<span class="text-slate-600 dark:text-slate-400">Jeu en cours</span>
</div>
<div class="flex items-center gap-2">
<code class="px-2 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs font-mono">{0.title}</code>
<span class="text-slate-600 dark:text-slate-400">Titre du stream</span>
</div>
<div class="flex items-center gap-2">
<code class="px-2 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs font-mono">{0.language}</code>
<span class="text-slate-600 dark:text-slate-400">Langue du stream</span>
</div>
</div>
</div>
<div class="flex items-center justify-end gap-3">
{% if alert %}
<a href="{{ url_for('openLiveAlert') }}" class="px-4 py-2 text-slate-700 dark:text-slate-300 text-sm font-medium rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
Annuler
</a>
{% endif %}
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
{% if alert %}Enregistrer{% else %}Ajouter{% endif %}
</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -1,168 +0,0 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Modération</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Historique des actions de modération sur le serveur Discord.
</p>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
<details class="group">
<summary class="flex items-center justify-between px-5 py-4 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
<span class="font-medium text-slate-800 dark:text-white">Commandes de modération disponibles</span>
<svg class="w-5 h-5 text-slate-400 group-open:rotate-180 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</summary>
<div class="border-t border-slate-200 dark:border-slate-700">
<div class="divide-y divide-slate-200 dark:divide-slate-700 text-sm">
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
<code class="text-slate-700 dark:text-slate-300 font-mono">!averto @user raison</code>
<span class="text-slate-500 dark:text-slate-400">Avertit un utilisateur</span>
</div>
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
<code class="text-slate-700 dark:text-slate-300 font-mono">!delaverto id</code>
<span class="text-slate-500 dark:text-slate-400">Retire un avertissement</span>
</div>
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
<code class="text-slate-700 dark:text-slate-300 font-mono">!warnings [@user]</code>
<span class="text-slate-500 dark:text-slate-400">Liste les événements de modération</span>
</div>
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
<code class="text-slate-700 dark:text-slate-300 font-mono">!inspect @user</code>
<span class="text-slate-500 dark:text-slate-400">Informations sur un utilisateur</span>
</div>
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
<code class="text-slate-700 dark:text-slate-300 font-mono">!kick @user raison</code>
<span class="text-slate-500 dark:text-slate-400">Expulse un utilisateur</span>
</div>
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
<code class="text-slate-700 dark:text-slate-300 font-mono">!ban @user raison</code>
<span class="text-slate-500 dark:text-slate-400">Bannit un utilisateur</span>
</div>
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
<code class="text-slate-700 dark:text-slate-300 font-mono">!unban id</code>
<span class="text-slate-500 dark:text-slate-400">Révoque un bannissement</span>
</div>
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
<code class="text-slate-700 dark:text-slate-300 font-mono">!banlist</code>
<span class="text-slate-500 dark:text-slate-400">Liste des utilisateurs bannis</span>
</div>
</div>
</div>
</details>
</div>
{% if not event %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Événements de modération</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Type</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Utilisateur</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Date</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Raison</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Staff</th>
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
{% for mod_event in events %}
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
<td class="px-4 py-3">
{% if mod_event.type == 'ban' %}
<span class="text-xs font-medium text-red-600 dark:text-red-400">Ban</span>
{% elif mod_event.type == 'kick' %}
<span class="text-xs font-medium text-orange-600 dark:text-orange-400">Kick</span>
{% elif mod_event.type == 'warn' or mod_event.type == 'warning' %}
<span class="text-xs font-medium text-yellow-600 dark:text-yellow-400">Warn</span>
{% elif mod_event.type == 'unban' %}
<span class="text-xs font-medium text-green-600 dark:text-green-400">Unban</span>
{% else %}
<span class="text-xs font-medium text-slate-600 dark:text-slate-400">{{ mod_event.type }}</span>
{% endif %}
</td>
<td class="px-4 py-3">
<div class="flex flex-col">
<span class="text-sm font-medium text-slate-800 dark:text-white">{{ mod_event.username }}</span>
<span class="text-xs text-slate-500 dark:text-slate-400 font-mono">{{ mod_event.discord_id }}</span>
</div>
</td>
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400 whitespace-nowrap">
{{ mod_event.created_at.strftime('%d/%m/%Y %H:%M') if mod_event.created_at else 'N/A' }}
</td>
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400 max-w-xs">
<div class="line-clamp-2">{{ mod_event.reason }}</div>
</td>
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400">
{{ mod_event.staff_name }}
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('open_edit_moderation_event', event_id = mod_event.id) }}" class="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors">
Modifier
</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="text-sm text-slate-500 hover:text-red-600 dark:hover:text-red-400 transition-colors">
Supprimer
</a>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="px-4 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
Aucun événement de modération
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if event %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-5">
<h2 class="text-lg font-medium text-slate-800 dark:text-white mb-5">Modifier l'événement</h2>
<form action="{{ url_for('update_moderation_event', event_id = event.id) }}" method="POST" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Type</label>
<input type="text" value="{{ event.type }}" disabled class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 cursor-not-allowed">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Staff</label>
<input type="text" value="{{ event.staff_name }}" disabled class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 cursor-not-allowed">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Utilisateur</label>
<input type="text" value="{{ event.username }}" disabled class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 cursor-not-allowed">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Discord ID</label>
<input type="text" value="{{ event.discord_id }}" disabled class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 cursor-not-allowed font-mono">
</div>
</div>
<div>
<label for="reason" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Raison</label>
<input type="text" name="reason" id="reason" value="{{ event.reason }}" required class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
<div class="flex items-center justify-end gap-3">
<a href="{{ url_for('moderation') }}" class="px-4 py-2 text-slate-700 dark:text-slate-300 text-sm font-medium rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
Annuler
</a>
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
Enregistrer
</button>
</div>
</form>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,124 +1,61 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">ProtonDB</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Compatibilité des jeux Windows sur Linux via Steam Play.
</p>
</div>
<h1>Proton DB</h1>
<p>ProtonDB évalue la compatibilité des jeux Windows sur Linux via Steam Play.</p>
{% if configuration.getValue('proton_db_enable_enable') %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Alias de jeux</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<h2>Game alias</h2>
<table>
<thead>
<tr class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Alias</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Nom du jeu</th>
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Actions</th>
<tr>
<th>Alias</th>
<th>Game</th>
<th>#</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<tbody>
{% for a in aliases %}
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
<td class="px-4 py-3">
<code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded text-xs font-mono">{{ a.alias }}</code>
</td>
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400">
{{ a.name }}
</td>
<td class="px-4 py-3 text-right">
<a href="{{ url_for('delGameAlias', id = a.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet alias ?')" class="text-sm text-slate-500 hover:text-red-600 dark:hover:text-red-400 transition-colors">
Supprimer
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="px-4 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
Aucun alias configuré
</td>
<td>{{a.alias}}</td>
<td>{{a.name}}</td>
<td><a href="{{ url_for('delGameAlias', id = a.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet alias ?')">Supprimer</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</table>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-5 mb-6">
<h2 class="text-lg font-medium text-slate-800 dark:text-white mb-5">Ajouter un alias</h2>
<form action="{{ url_for('addGameAlias') }}" method="POST" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="alias" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Alias</label>
<input type="text" name="alias" id="alias" maxlength="32" required placeholder="GTA" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
<div>
<label for="name" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Nom complet</label>
<input type="text" name="name" id="name" maxlength="256" required placeholder="Grand Theft Auto" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
Ajouter
</button>
</div>
</form>
<div class="mt-5 bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4">
<p class="text-sm text-slate-600 dark:text-slate-400">
<strong class="text-slate-800 dark:text-white">Exemple :</strong> Si vous créez un alias GTA → Grand Theft Auto, alors !protondb GTA 5 fera une recherche sur Grand Theft Auto 5.
<h2>Ajouter un Alias</h2>
<form action="{{ url_for('addGameAlias') }}" method="POST">
<label for="alias">Alias</label>
<input name="alias" type="text" maxlength="32" required="required" />
<label for="name">Nom</label>
<input name="name" type="text" maxlength="256" required="required" />
<input type="Submit" value="Ajouter">
<p>Si vous créez un alias <strong>GTA : Grand Theft Auto</strong> alors si un utilisateur rentre la commande
<strong>!protondb GTA 5</strong> cela fera une recherche sur <strong>Grand Theft Auto 5</strong>.
</p>
</div>
</div>
</form>
{% endif %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Configuration</h2>
</div>
<h2>Configuration</h2>
<form action="{{ url_for('updateConfiguration') }}" method="POST">
<label for="proton_db_enable_enable">Activer</label>
<input type="checkbox" name="proton_db_enable_enable" {% if configuration.getValue('proton_db_enable_enable') %}
checked="checked" {% endif %}>
<label>Activer la commande Proton DB</label>
<label for="proton_db_api_id">API ID</label>
<input name="proton_db_api_id" type="text" value="{{ configuration.getValue('proton_db_api_id') }}" />
<label for="proton_db_api_key">Clé API</label>
<input name="proton_db_api_key" type="text" value="{{ configuration.getValue('proton_db_api_key') }}" />
<input type="Submit" value="Définir">
<p>Pour trouver les clés, dans votre navigateur avec l'outil d'inspection ouvert (F12 ou clic droit > Inspecter
l'élément dans Firefox/Chrome) faites une recherche de jeux sur protondb,
puis cherchez les clés dans les requêtes (onglet Réseau/Network),
<a href="/static/img/algolia-key.jpg" target="_blank">comme le montre cet exemple</a>
</p>
</form>
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="p-5 space-y-6">
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="proton_db_enable_enable" {% if configuration.getValue('proton_db_enable_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
<span class="text-sm text-slate-700 dark:text-slate-300">Activer la commande ProtonDB</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="proton_db_api_id" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">API ID</label>
<input type="text" name="proton_db_api_id" id="proton_db_api_id" value="{{ configuration.getValue('proton_db_api_id') }}" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
<div>
<label for="proton_db_api_key" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Clé API</label>
<input type="text" name="proton_db_api_key" id="proton_db_api_key" value="{{ configuration.getValue('proton_db_api_key') }}" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
</div>
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
Enregistrer
</button>
</form>
<div class="px-5 pb-5">
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4">
<p class="text-sm font-medium text-slate-800 dark:text-white mb-2">Comment trouver les clés API ?</p>
<ol class="list-decimal list-inside space-y-1 text-sm text-slate-600 dark:text-slate-400">
<li>Ouvrez l'outil d'inspection de votre navigateur (F12)</li>
<li>Allez dans l'onglet Réseau/Network</li>
<li>Faites une recherche de jeu sur ProtonDB</li>
<li>Cherchez les clés dans les requêtes réseau</li>
</ol>
<a href="/static/img/algolia-key.jpg" target="_blank" class="inline-block mt-3 text-sm text-slate-600 dark:text-slate-400 hover:underline">
Voir l'exemple en image
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,218 +1,38 @@
<!DOCTYPE html>
<html lang="fr" class="h-full">
<html color-mode="user">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="mobile-web-app-capable" content="yes">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
<meta name="mobile-web-app-capable" content="yes" />
<title>Mamie Henriette</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
accent: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
}
}
}
}
}
</script>
<link rel="stylesheet" href="/static/css/mvp.css" />
<link rel="stylesheet" href="/static/css/style.css" />
<link rel="icon" href="/static/ico/favicon.ico" type="image/x-icon">
<link rel="shortcut icon" href="/static/ico/favicon.ico" type="image/x-icon">
<style>
/* Animations personnalisées */
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Scrollbar personnalisée */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
.dark ::-webkit-scrollbar-thumb { background: #475569; }
.dark ::-webkit-scrollbar-thumb:hover { background: #64748b; }
</style>
</head>
<body class="h-full bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-200">
<!-- Navbar -->
<nav class="fixed top-0 left-0 right-0 z-50 bg-white dark:bg-gray-800 shadow-md border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<a href="/" class="flex items-center gap-3 group">
<img src="/static/ico/favicon.ico" alt="Mamie Henriette" class="w-10 h-10 rounded-full ring-2 ring-slate-200 dark:ring-slate-600 group-hover:ring-slate-300 dark:group-hover:ring-slate-500 transition-all">
<span class="font-bold text-xl text-slate-800 dark:text-white hidden sm:block">Mamie Henriette</span>
</a>
<!-- Navigation Desktop -->
<div class="hidden md:flex items-center gap-1">
<a href="/live-alert" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
<span class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
Alerte live
</span>
</a>
<a href="/commandes" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
<span class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
Commandes
</span>
</a>
<a href="/humeurs" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
<span class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
Humeurs
</span>
</a>
<a href="/moderation" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
<span class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
Modération
</span>
</a>
<a href="/protondb" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
<span class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
ProtonDB
</span>
</a>
<a href="/configurations" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
<span class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
Configurations
</span>
</a>
</div>
<!-- Actions -->
<div class="flex items-center gap-2">
<!-- Dark Mode Toggle -->
<button onclick="toggleDarkMode()" class="p-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all" title="Mode sombre">
<svg class="w-5 h-5 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
<svg class="w-5 h-5 block dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
</button>
<!-- Mobile Menu Button -->
<button onclick="toggleMobileMenu()" class="md:hidden p-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
</button>
</div>
</div>
</div>
<!-- Mobile Menu -->
<div id="mobile-menu" class="hidden md:hidden border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div class="px-4 py-3 space-y-1">
<a href="/live-alert" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
Alerte live
</a>
<a href="/commandes" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
Commandes
</a>
<a href="/humeurs" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
Humeurs
</a>
<a href="/moderation" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
Modération
</a>
<a href="/protondb" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
ProtonDB
</a>
<a href="/configurations" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
Configurations
</a>
</div>
</div>
<body>
<header>
<nav>
<a href="/"><img src="/static/ico/favicon.ico"></a>
<ul>
<li><a href="/live-alert">Alerte live</a></li>
<li><a href="/commandes">Commandes</a></li>
<li><a href="/humeurs">Humeurs</a></li>
<li><a href="/protondb">ProtonDB</a></li>
<li><a href="/configurations">Configurations</a></li>
</ul>
</nav>
<!-- Main Content -->
<main class="pt-20 pb-12 min-h-screen">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="fade-in">
</header>
<main>
{% block content %}{% endblock %}
</div>
</div>
</main>
<!-- Footer -->
<footer class="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<img src="/static/ico/favicon.ico" alt="" class="w-6 h-6 rounded-full">
<span>Mamie Henriette</span>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">
Créé par la communauté
<a href="https://discord.com/invite/UwAPqMJnx3" target="_blank" class="text-primary-600 dark:text-primary-400 hover:underline">Discord</a>
de
<a href="https://www.youtube.com/@513v3" target="_blank" class="text-primary-600 dark:text-primary-400 hover:underline">573v3</a>
</p>
<div class="flex items-center gap-4">
<a href="https://github.com/skylanix/MamieHenriette" target="_blank" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path></svg>
</a>
<span class="text-xs text-gray-400 dark:text-gray-500">AGPLv3</span>
</div>
</div>
</div>
<footer>
<hr>
<p><a href="https://github.com/skylanix/MamieHenriette" target="_blank">MamieHenriette</a> créé par la communauté <a href="https://discord.com/invite/UwAPqMJnx3" target="_blank">Discord</a> de <a href="https://www.youtube.com/@513v3" target="_blank">573v3</a> - Projet open source sous licence <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank">AGPLv3</a></p>
</footer>
<script>
// Dark mode
function toggleDarkMode() {
document.documentElement.classList.toggle('dark');
localStorage.setItem('darkMode', document.documentElement.classList.contains('dark'));
}
// Initialize dark mode from preference
if (localStorage.getItem('darkMode') === 'true' ||
(!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
// Mobile menu
function toggleMobileMenu() {
document.getElementById('mobile-menu').classList.toggle('hidden');
}
</script>
</body>
</html>

View File

@@ -1,92 +1,35 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Configuration Twitch</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Guide étape par étape pour configurer l'API Twitch.
</p>
</div>
<h1>Procédure de configuration de Twitch</h1>
<p>
<strong>Avant toute chose, activez l'authentification à deux facteurs (2FA) :</strong>
<a href="https://help.twitch.tv/s/article/two-factor-authentication?language=en_US" target="_blank">Guide officiel
Twitch pour la 2FA</a>
</p>
<p>
Rendez-vous sur <a href="https://dev.twitch.tv/console" target="_blank">la console d'applications Twitch</a> et
ajoutez une application. Renseignez :
<ul>
<li>URL de redirection : {{token_redirect_url}}</li>
<li>Catégorie : Chat Bot</li>
</ul>
</p>
<div class="space-y-4">
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-400 text-xs font-medium">1</span>
<h2 class="font-medium text-slate-800 dark:text-white">Activer l'authentification à deux facteurs (2FA)</h2>
</div>
</div>
<div class="p-5">
<p class="text-sm text-slate-600 dark:text-slate-400 mb-4">
Avant de créer une application Twitch, vous devez activer la 2FA sur votre compte.
</p>
<a href="https://help.twitch.tv/s/article/two-factor-authentication?language=en_US" target="_blank" class="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-white">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
Guide officiel Twitch
</a>
</div>
</div>
<img src="/static/img/twitch-api-01.jpg">
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-400 text-xs font-medium">2</span>
<h2 class="font-medium text-slate-800 dark:text-white">Créer une application Twitch</h2>
</div>
</div>
<div class="p-5 space-y-4">
<p class="text-sm text-slate-600 dark:text-slate-400">
Rendez-vous sur la console Twitch et créez une nouvelle application :
</p>
<a href="https://dev.twitch.tv/console" target="_blank" class="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-800 dark:bg-slate-700 text-white text-sm rounded-lg hover:bg-slate-700 dark:hover:bg-slate-600 transition-colors">
Console Twitch
</a>
<p>
Créez le bot. Puis, de retour à la liste, éditez-le en cliquant sur Gérer. Puis cliquez sur <strong>Nouveau
Secret</strong>. Vous trouverez ici le <strong>Client ID</strong> et le <strong>Client Secret</strong>.
</p>
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4 space-y-2 text-sm">
<div><span class="text-slate-500 dark:text-slate-400">URL de redirection :</span> <code class="ml-1 px-1.5 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs">{{ token_redirect_url }}</code></div>
<div><span class="text-slate-500 dark:text-slate-400">Catégorie :</span> <span class="ml-1 text-slate-800 dark:text-white">Chat Bot</span></div>
</div>
<img src="/static/img/twitch-api-02.jpg">
<div class="rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
<img src="/static/img/twitch-api-01.jpg" alt="Création d'application Twitch" class="w-full">
</div>
</div>
</div>
<p>
Ensuite, retournez sur la page de <a href="{{url_for('openConfigurations')}}">Configuration</a>, après avoir
enregistré le <strong>Client ID</strong> et le <strong>Client Secret</strong>, cliquez sur le lien <strong>Obtenir
token et refresh token</strong>. Si tout se passe bien les champs <strong>Access Token</strong> et
<strong>Refresh Token</strong> sont remplis.
</p>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-400 text-xs font-medium">3</span>
<h2 class="font-medium text-slate-800 dark:text-white">Récupérer les identifiants</h2>
</div>
</div>
<div class="p-5 space-y-4">
<p class="text-sm text-slate-600 dark:text-slate-400">
Cliquez sur <strong class="text-slate-800 dark:text-white">Gérer</strong> puis <strong class="text-slate-800 dark:text-white">Nouveau Secret</strong> pour obtenir le Client ID et Client Secret.
</p>
<div class="rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
<img src="/static/img/twitch-api-02.jpg" alt="Récupération des identifiants" class="w-full">
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-400 text-xs font-medium">4</span>
<h2 class="font-medium text-slate-800 dark:text-white">Configurer Mamie Henriette</h2>
</div>
</div>
<div class="p-5 space-y-4">
<ol class="list-decimal list-inside space-y-1 text-sm text-slate-600 dark:text-slate-400">
<li>Entrez le Client ID et Client Secret</li>
<li>Cliquez sur Enregistrer</li>
<li>Cliquez sur "Obtenir token et refresh token"</li>
</ol>
<a href="{{ url_for('openConfigurations') }}" class="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-800 dark:bg-slate-700 text-white text-sm rounded-lg hover:bg-slate-700 dark:hover:bg-slate-600 transition-colors">
Aller à la Configuration
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -20,10 +20,24 @@ def twitchConfigurationHelp():
@webapp.route("/configurations/twitch/request-token")
async def twitchRequestToken():
global auth
try:
helper = ConfigurationHelper()
twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
import asyncio
twitch = await asyncio.wait_for(
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")
async def twitchReceiveToken():
@@ -31,20 +45,25 @@ async def twitchReceiveToken():
state = request.args.get('state')
code = request.args.get('code')
if state != auth.state :
logging('bad returned state')
logging.error('bad returned state')
return redirect(url_for('openConfigurations'))
if code == None :
logging('no returned state')
logging.error('no returned code')
return redirect(url_for('openConfigurations'))
try:
token, refresh = await auth.authenticate(user_token=code)
import asyncio
token, refresh = await asyncio.wait_for(auth.authenticate(user_token=code), timeout=30.0)
helper = ConfigurationHelper()
helper.createOrUpdate('twitch_access_token', token)
helper.createOrUpdate('twitch_refresh_token', refresh)
db.session.commit()
except asyncio.TimeoutError:
logging.error('Timeout lors de l\'authentification Twitch')
except TwitchAPIException as e:
logging(e)
logging.error(f'Erreur API Twitch lors de l\'authentification : {e}')
except Exception as e:
logging.error(f'Erreur inattendue lors de l\'authentification Twitch : {e}')
return redirect(url_for('openConfigurations'))
# hack pas fou mais on estime qu'on sera toujours en ssl en connecté