mirror of
https://github.com/skylanix/MamieHenriette.git
synced 2026-02-06 14:50:34 +01:00
Compare commits
40 Commits
22abbcb02d
...
youtube-no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10906bdae3 | ||
|
|
48531690fd | ||
|
|
9bbfa1fade | ||
|
|
920ddfa172 | ||
|
|
a8d2a0e063 | ||
|
|
f2cd19a053 | ||
|
|
4973144e54 | ||
|
|
9afd3b2588 | ||
|
|
54b014c4c8 | ||
|
|
559a780a4f | ||
|
|
9abd7b8101 | ||
|
|
a66c31ecf6 | ||
|
|
499fac9c12 | ||
|
|
3e12c2cf08 | ||
|
|
5c76b50797 | ||
|
|
d5d3e45a62 | ||
|
|
cb559c2863 | ||
|
|
a0a14abf57 | ||
|
|
a987ca311e | ||
|
|
6411b1e73c | ||
|
|
3b2886a41f | ||
|
|
0e43313366 | ||
|
|
0b9b9a4a23 | ||
|
|
4a3cf400a0 | ||
|
|
c45f83df6c | ||
|
|
8a194f7b0e | ||
|
|
30d0a4160b | ||
|
|
95edb9a523 | ||
|
|
81be00da28 | ||
|
|
9cdf26c3ba | ||
|
|
d63d81f2b8 | ||
|
|
a26214ed68 | ||
|
|
18a883c27b | ||
|
|
eb9bf0e67e | ||
|
|
db03c382cd | ||
|
|
02abe1e1a7 | ||
|
|
2815022219 | ||
|
|
6a171e795f | ||
|
|
fd172e2ea0 | ||
|
|
aff236fd0c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@
|
||||
__pycache__
|
||||
instance
|
||||
.tio.tokens.json
|
||||
**/logs
|
||||
|
||||
192
README.md
192
README.md
@@ -14,6 +14,8 @@
|
||||
- [Prérequis](#prérequis)
|
||||
- [Création du bot Discord](#création-du-bot-discord)
|
||||
- [Démarrage rapide](#démarrage-rapide)
|
||||
- [Build local (développement)](#build-local-développement)
|
||||
- [Déploiement avec Portainer](#déploiement-avec-portainer)
|
||||
- [Volumes persistants](#volumes-persistants)
|
||||
- [Commandes Docker utiles](#commandes-docker-utiles)
|
||||
- [Mise à jour](#mise-à-jour)
|
||||
@@ -50,12 +52,49 @@ Mamie Henriette est un bot intelligent open-source développé spécifiquement p
|
||||
- **Statuts dynamiques** : Rotation automatique des humeurs (10 min)
|
||||
- **Notifications Humble Bundle** : Surveillance et alertes automatiques (30 min)
|
||||
- **Commandes personnalisées** : Gestion via interface web
|
||||
- **Recherche ProtonDB** : Commande `!protondb <nom_du_jeu>` pour vérifier la compatibilité Linux/Steam Deck
|
||||
- **Modération** : Outils intégrés
|
||||
- **Recherche ProtonDB** :
|
||||
- Commande `!protondb nom_du_jeu` ou `!pdb nom_du_jeu` pour vérifier la compatibilité Linux/Steam Deck
|
||||
- Recherche intelligente avec support des alias de jeux
|
||||
- Affichage du score de compatibilité, nombre de rapports et lien direct
|
||||
- **Intégration anti-cheat** : Affiche automatiquement les systèmes anti-cheat et leur statut (supporté, cassé, refusé)
|
||||
- Cache mis à jour automatiquement depuis AreWeAntiCheatYet
|
||||
- **Modération** : Système complet de modération avec historique
|
||||
- **Avertissements** : `!averto`, `!warn`, `!av`, `!avertissement`
|
||||
- Envoi automatique de DM à l'utilisateur averti
|
||||
- Support des timeouts combinés : `!warn @user raison --to durée`
|
||||
- **Timeout** : `!timeout`, `!to` - Exclusion temporaire d'un utilisateur
|
||||
- Syntaxe : `!to @user durée raison` (ex: `!to @User 10m Spam`)
|
||||
- Durées supportées : secondes (s), minutes (m), heures (h), jours (j/days)
|
||||
- **Gestion des avertissements** : `!delaverto`, `!removewarn`, `!delwarn`
|
||||
- **Liste des événements** : `!warnings`, `!listevent`, `!listwarn`
|
||||
- **Inspection utilisateur** : `!inspect @user`
|
||||
- Historique complet des sanctions
|
||||
- Date d'arrivée et durée sur le serveur
|
||||
- Détection des comptes suspects (< 7 jours)
|
||||
- Affichage du code d'invitation utilisé et de l'inviteur
|
||||
- **Bannissement** : `!ban @user raison`, `!banlist`
|
||||
- `!unban @user raison` ou `!unban #ID raison` (débannir par ID de sanction)
|
||||
- Invitation automatique par DM lors du débannissement
|
||||
- **Expulsion** : `!kick @user raison`
|
||||
- **Annonces** : `!say #canal message` - Envoi de messages en tant que bot (staff uniquement)
|
||||
- **Aide** : `!aide`, `!help` - Liste complète des commandes disponibles
|
||||
- **Configuration avancée** :
|
||||
- Support de multiples rôles staff
|
||||
- Canal de logs dédié pour toutes les actions
|
||||
- Suppression automatique des messages de modération (délai configurable)
|
||||
- Activation/désactivation individuelle des fonctionnalités
|
||||
- Panneau d'administration web pour consulter, éditer et supprimer l'historique
|
||||
- **Messages de bienvenue et départ** :
|
||||
- Messages personnalisables avec variables : `{member.mention}`, `{member.name}`, `{server.name}`, `{server.member_count}`
|
||||
- **Système de tracking d'invitations** : Affiche qui a invité le nouveau membre
|
||||
- **Messages de départ intelligents** : Détection automatique de la raison (volontaire, kick, ban)
|
||||
- Affichage de la durée passée sur le serveur
|
||||
- Embeds enrichis avec avatar et informations détaillées
|
||||
|
||||
### Twitch
|
||||
- **Chat bot** : Commandes et interactions automatiques
|
||||
- **Alertes Live** : Surveillance automatique des streamers (vérification toutes les 5 minutes)
|
||||
- **Alertes Live** :
|
||||
- Surveillance automatique des streamers
|
||||
- Support jusqu'à 100 chaînes simultanément
|
||||
- Notifications Discord avec aperçu du stream
|
||||
- Gestion via interface d'administration
|
||||
@@ -67,10 +106,23 @@ Mamie Henriette est un bot intelligent open-source développé spécifiquement p
|
||||
|
||||
### Interface d'administration
|
||||
- **Dashboard** : Vue d'ensemble et statistiques
|
||||
- **Configuration** : Tokens, paramètres des plateformes, configuration ProtonDB
|
||||
- **Gestion des humeurs** : Création et modification des statuts
|
||||
- **Commandes** : Édition des commandes personnalisées
|
||||
- **Modération** : Outils de gestion communautaire
|
||||
- **Configuration** :
|
||||
- Tokens Discord/Twitch et paramètres des plateformes
|
||||
- Configuration ProtonDB (API Algolia)
|
||||
- Gestion des rôles staff (support de multiples rôles)
|
||||
- Activation/désactivation individuelle des fonctionnalités (modération, ban, kick, welcome, leave)
|
||||
- Configuration du délai de suppression automatique des messages de modération
|
||||
- **Gestion des humeurs** : Création et modification des statuts Discord rotatifs
|
||||
- **Commandes** : Édition des commandes personnalisées multi-plateformes
|
||||
- **Modération** :
|
||||
- Consultation de l'historique complet des sanctions
|
||||
- Édition des raisons des événements de modération
|
||||
- Suppression d'événements de modération
|
||||
- Filtrage et recherche dans l'historique
|
||||
- **Messages de bienvenue/départ** :
|
||||
- Personnalisation des messages avec variables dynamiques
|
||||
- Configuration des canaux de bienvenue et départ
|
||||
- Activation/désactivation indépendante
|
||||
|
||||
|
||||
## Installation
|
||||
@@ -115,22 +167,123 @@ Avant d'installer MamieHenriette, vous devez créer un bot Discord et obtenir so
|
||||
```bash
|
||||
# 1. Cloner le projet
|
||||
git clone https://github.com/skylanix/MamieHenriette.git
|
||||
```
|
||||
|
||||
```bash
|
||||
cd MamieHenriette
|
||||
```
|
||||
|
||||
```bash
|
||||
# 2. Lancer avec Docker
|
||||
docker compose up --build -d
|
||||
# 2. Récupérer l'image depuis GitHub Container Registry et lancer
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> ⚠️ **Important** : Après configuration via l'interface web http://localhost:5000, **redémarrez le conteneur** pour que les changements soient pris en compte :
|
||||
> 📝 L'interface web sera accessible sur http://localhost:5000
|
||||
>
|
||||
> ⚠️ **Important** : Après configuration via l'interface web, **redémarrez le conteneur** pour que les changements soient pris en compte :
|
||||
> ```bash
|
||||
> docker compose restart MamieHenriette
|
||||
> ```
|
||||
|
||||
### Build local (développement)
|
||||
|
||||
Si vous souhaitez modifier le code et builder l'image localement :
|
||||
|
||||
```bash
|
||||
# 1. Cloner et accéder au projet
|
||||
git clone https://github.com/skylanix/MamieHenriette.git
|
||||
cd MamieHenriette
|
||||
```
|
||||
|
||||
```bash
|
||||
# 2. Modifier le docker-compose.yml
|
||||
# Commentez la ligne 'image:' et décommentez la section 'build:' :
|
||||
```
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mamiehenriette:
|
||||
container_name: MamieHenriette
|
||||
restart: unless-stopped
|
||||
build: . # ← Décommentez cette ligne
|
||||
image: mamiehenriette # ← Décommentez cette ligne
|
||||
# image: ghcr.io/skylanix/mamiehenriette:latest # ← Commentez cette ligne
|
||||
# ... reste de la configuration
|
||||
```
|
||||
|
||||
```bash
|
||||
# 3. Builder et lancer
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
### Déploiement avec Portainer
|
||||
|
||||
Si vous utilisez Portainer pour gérer vos conteneurs Docker, voici la configuration Docker Compose à utiliser :
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mamiehenriette:
|
||||
container_name: MamieHenriette
|
||||
image: ghcr.io/skylanix/mamiehenriette:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: Europe/Paris
|
||||
volumes:
|
||||
# Adaptez ces chemins selon votre configuration
|
||||
- ./instance:/app/instance
|
||||
- ./logs:/app/logs
|
||||
ports:
|
||||
- 5000:5000
|
||||
|
||||
watchtower: # Mise à jour automatique de l'image
|
||||
image: containrrr/watchtower:latest
|
||||
container_name: watchtower
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: Europe/Paris
|
||||
WATCHTOWER_INCLUDE: "MamieHenriette"
|
||||
WATCHTOWER_SCHEDULE: "0 */30 * * * *" # Vérification toutes les 30 min
|
||||
WATCHTOWER_MONITOR_ONLY: "false"
|
||||
WATCHTOWER_CLEANUP: "true"
|
||||
WATCHTOWER_INCLUDE_RESTARTING: "true"
|
||||
# Décommentez pour activer les notifications Discord :
|
||||
# WATCHTOWER_NOTIFICATION_URL: "discord://token@id"
|
||||
# WATCHTOWER_NOTIFICATIONS: shoutrrr
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
# Décommentez pour accéder à la base de données via interface web (localhost:5001)
|
||||
# sqlite-web:
|
||||
# image: ghcr.io/coleifer/sqlite-web:latest
|
||||
# container_name: sqlite_web
|
||||
# ports:
|
||||
# - "5001:8080"
|
||||
# volumes:
|
||||
# - ./instance/database.db:/data/database.db
|
||||
# environment:
|
||||
# - SQLITE_DATABASE=/data/database.db
|
||||
```
|
||||
|
||||
**Étapes dans Portainer :**
|
||||
|
||||
1. **Accéder à Portainer** : Ouvrez votre interface Portainer (généralement http://votre-serveur:9000)
|
||||
|
||||
2. **Créer une Stack** :
|
||||
- Allez dans "Stacks" → "Add stack"
|
||||
- Donnez un nom : `MamieHenriette`
|
||||
- Collez la configuration ci-dessus dans l'éditeur
|
||||
|
||||
3. **Adapter les chemins des volumes** :
|
||||
- Modifiez `./instance` et `./logs` selon votre configuration
|
||||
- Exemple : `/opt/containers/MamieHenriette/instance` et `/opt/containers/MamieHenriette/logs`
|
||||
|
||||
4. **Déployer** :
|
||||
- Cliquez sur "Deploy the stack"
|
||||
- Attendez que le conteneur démarre
|
||||
|
||||
5. **Accéder à l'interface** :
|
||||
- Ouvrez http://votre-serveur:5000
|
||||
- Configurez le bot via l'interface web
|
||||
- Redémarrez le conteneur depuis Portainer après configuration
|
||||
|
||||
### Volumes persistants
|
||||
- `./instance/` : Base de données SQLite et configuration
|
||||
- `./logs/` : Logs applicatifs rotatifs (50MB max par fichier)
|
||||
@@ -165,10 +318,12 @@ git pull origin main
|
||||
# 3. Mettre à jour l'image Docker
|
||||
docker compose pull
|
||||
|
||||
# 4. Reconstruire et relancer
|
||||
docker compose up --build -d
|
||||
# 4. Relancer
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> 💡 **Note** : Si vous utilisez Watchtower, les mises à jour de l'image sont automatiques (vérification toutes les 30 minutes).
|
||||
|
||||
#### Sans Docker (installation locale)
|
||||
```bash
|
||||
# 1. Arrêter l'application
|
||||
@@ -236,13 +391,16 @@ python run-web.py
|
||||
## Spécifications techniques
|
||||
|
||||
### Base de données (SQLite)
|
||||
- **Configuration** : Paramètres et tokens des plateformes
|
||||
- **Configuration** : Paramètres et tokens des plateformes, configuration des fonctionnalités
|
||||
- **Humeur** : Statuts Discord rotatifs avec gestion automatique
|
||||
- **Commande** : Commandes personnalisées multi-plateformes (Discord/Twitch)
|
||||
- **LiveAlert** : Configuration surveillance streamers Twitch (nom, canal Discord, statut)
|
||||
- **GameAlias** : Alias pour améliorer les recherches ProtonDB
|
||||
- **GameBundle** : Historique et notifications Humble Bundle
|
||||
- **Message** : Messages automatiques périodiques (implémenté)
|
||||
- **AntiCheatCache** : Cache des informations anti-cheat pour ProtonDB (mise à jour automatique hebdomadaire)
|
||||
- **Message** : Messages automatiques périodiques
|
||||
- **Moderation** : Historique complet des actions de modération (avertissements, timeouts, bans, kicks, unbans) avec raison, staff, timestamp et durée
|
||||
- **MemberInvites** : Tracking des invitations (code d'invitation, inviteur, date de join)
|
||||
|
||||
### Architecture multi-thread
|
||||
- **Thread 1** : Interface web Flask (port 5000) avec logging rotatif
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlite3 import Cursor, Connection
|
||||
@@ -9,9 +11,35 @@ 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)
|
||||
|
||||
def _tableHaveColumn(table_name:str, column_name:str, cursor:Cursor) -> bool:
|
||||
# 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 _tableExists(table_name: str, cursor: Cursor) -> bool:
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
def _tableHaveColumn(table_name:str, column_name:str, cursor:Cursor) -> bool:
|
||||
if not _tableExists(table_name, cursor):
|
||||
return False
|
||||
cursor.execute(f'PRAGMA table_info({table_name})')
|
||||
columns = cursor.fetchall()
|
||||
return any(col[1] == column_name for col in columns)
|
||||
@@ -43,6 +71,25 @@ def _doPostImportMigration(cursor:Cursor):
|
||||
logging.info("suppression de la table temporaire game_bundle_old")
|
||||
_dropTable('game_bundle_old', cursor)
|
||||
|
||||
if _tableExists('youtube_notification', cursor):
|
||||
embed_columns = [
|
||||
('embed_title', 'VARCHAR(256)'),
|
||||
('embed_description', 'VARCHAR(2000)'),
|
||||
('embed_color', 'VARCHAR(8) DEFAULT "FF0000"'),
|
||||
('embed_footer', 'VARCHAR(2048)'),
|
||||
('embed_author_name', 'VARCHAR(256)'),
|
||||
('embed_author_icon', 'VARCHAR(512)'),
|
||||
('embed_thumbnail', 'BOOLEAN DEFAULT 1'),
|
||||
('embed_image', 'BOOLEAN DEFAULT 1'),
|
||||
]
|
||||
for col_name, col_type in embed_columns:
|
||||
if not _tableHaveColumn('youtube_notification', col_name, cursor):
|
||||
try:
|
||||
cursor.execute(f'ALTER TABLE youtube_notification ADD COLUMN {col_name} {col_type}')
|
||||
logging.info(f"Colonne {col_name} ajoutée à youtube_notification")
|
||||
except Exception as e:
|
||||
logging.warning(f"Colonne youtube_notification.{col_name}: {e}")
|
||||
|
||||
with webapp.app_context():
|
||||
with open('database/schema.sql', 'r') as f:
|
||||
sql = f.read()
|
||||
|
||||
@@ -40,3 +40,43 @@ 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)
|
||||
|
||||
|
||||
class YouTubeNotification(db.Model):
|
||||
__tablename__ = 'youtube_notification'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
enable = db.Column(db.Boolean, default=True)
|
||||
channel_id = db.Column(db.String(128))
|
||||
notify_channel = db.Column(db.Integer)
|
||||
message = db.Column(db.String(2000))
|
||||
video_type = db.Column(db.String(16), default='all')
|
||||
last_video_id = db.Column(db.String(128))
|
||||
embed_title = db.Column(db.String(256))
|
||||
embed_description = db.Column(db.String(2000))
|
||||
embed_color = db.Column(db.String(8), default='FF0000')
|
||||
embed_footer = db.Column(db.String(2048))
|
||||
embed_author_name = db.Column(db.String(256))
|
||||
embed_author_icon = db.Column(db.String(512))
|
||||
embed_thumbnail = db.Column(db.Boolean, default=True)
|
||||
embed_image = db.Column(db.Boolean, default=True)
|
||||
|
||||
|
||||
@@ -45,3 +45,52 @@ 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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `youtube_notification` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`enable` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`channel_id` VARCHAR(128) NOT NULL,
|
||||
`notify_channel` INTEGER NOT NULL,
|
||||
`message` VARCHAR(2000) NOT NULL,
|
||||
`video_type` VARCHAR(16) NOT NULL DEFAULT 'all',
|
||||
`last_video_id` VARCHAR(128),
|
||||
`embed_title` VARCHAR(256),
|
||||
`embed_description` VARCHAR(2000),
|
||||
`embed_color` VARCHAR(8) NOT NULL DEFAULT 'FF0000',
|
||||
`embed_footer` VARCHAR(2048),
|
||||
`embed_author_name` VARCHAR(256),
|
||||
`embed_author_icon` VARCHAR(512),
|
||||
`embed_thumbnail` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`embed_image` BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
@@ -3,21 +3,46 @@ import discord
|
||||
import logging
|
||||
import random
|
||||
|
||||
from webapp import webapp
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import Configuration, Humeur, Commande
|
||||
from discord import Message, TextChannel
|
||||
from discord import Message, TextChannel, Member
|
||||
from discordbot.humblebundle import checkHumbleBundleAndNotify
|
||||
from discordbot.moderation import (
|
||||
handle_warning_command,
|
||||
handle_remove_warning_command,
|
||||
handle_list_warnings_command,
|
||||
handle_ban_command,
|
||||
handle_kick_command,
|
||||
handle_unban_command,
|
||||
handle_inspect_command,
|
||||
handle_ban_list_command,
|
||||
handle_staff_help_command,
|
||||
handle_timeout_command,
|
||||
handle_say_command
|
||||
)
|
||||
from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
|
||||
from discordbot.youtube import checkYouTubeVideos
|
||||
from protondb import searhProtonDb
|
||||
|
||||
class DiscordBot(discord.Client):
|
||||
async def on_ready(self):
|
||||
logging.info(f'Connecté en tant que {self.user} (ID: {self.user.id})')
|
||||
webapp.config["BOT_STATUS"]["discord_connected"] = True
|
||||
webapp.config["BOT_STATUS"]["discord_guild_count"] = len(self.guilds)
|
||||
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())
|
||||
self.loop.create_task(self.updateYouTube())
|
||||
|
||||
async def on_disconnect(self):
|
||||
webapp.config["BOT_STATUS"]["discord_connected"] = False
|
||||
|
||||
async def updateStatus(self):
|
||||
while not self.is_closed():
|
||||
@@ -27,32 +52,52 @@ 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)
|
||||
|
||||
async def updateYouTube(self):
|
||||
while not self.is_closed():
|
||||
await checkYouTubeVideos()
|
||||
await asyncio.sleep(5*60)
|
||||
|
||||
def getAllTextChannel(self) -> list[TextChannel]:
|
||||
channels = []
|
||||
for channel in self.get_all_channels():
|
||||
if isinstance(channel, TextChannel):
|
||||
channels.append(channel)
|
||||
return channels
|
||||
|
||||
def getAllRoles(self):
|
||||
guilds_roles = []
|
||||
for guild in self.guilds:
|
||||
roles = []
|
||||
for role in guild.roles:
|
||||
if role.name != "@everyone":
|
||||
roles.append(role)
|
||||
if roles:
|
||||
guilds_roles.append({
|
||||
'guild_name': guild.name,
|
||||
'guild_id': guild.id,
|
||||
'roles': roles
|
||||
})
|
||||
return guilds_roles
|
||||
|
||||
|
||||
def begin(self) :
|
||||
token = Configuration.query.filter_by(key='discord_token').first()
|
||||
if token :
|
||||
if token and token.value and token.value.strip():
|
||||
self.run(token.value)
|
||||
else :
|
||||
logging.error('Aucun token Discord configuré. Le bot ne peut pas être démarré')
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
intents.members = True
|
||||
intents.invites = True
|
||||
bot = DiscordBot(intents=intents)
|
||||
|
||||
# https://discordpy.readthedocs.io/en/stable/quickstart.html
|
||||
@@ -63,6 +108,54 @@ async def on_message(message: Message):
|
||||
if not message.content.startswith('!'):
|
||||
return
|
||||
command_name = message.content.split()[0]
|
||||
|
||||
if ConfigurationHelper().getValue('moderation_enable'):
|
||||
if command_name in ['!averto', '!av', '!avertissement', '!warn']:
|
||||
await handle_warning_command(message, bot)
|
||||
return
|
||||
|
||||
if command_name in ['!to', '!timeout']:
|
||||
await handle_timeout_command(message, bot)
|
||||
return
|
||||
|
||||
if command_name in ['!delaverto', '!removewarn', '!unwarn']:
|
||||
await handle_remove_warning_command(message, bot)
|
||||
return
|
||||
|
||||
if command_name in ['!listevent', '!listwarn', '!warnings']:
|
||||
await handle_list_warnings_command(message, bot)
|
||||
return
|
||||
|
||||
if ConfigurationHelper().getValue('moderation_ban_enable'):
|
||||
if command_name == '!ban':
|
||||
await handle_ban_command(message, bot)
|
||||
return
|
||||
|
||||
if command_name == '!unban':
|
||||
await handle_unban_command(message, bot)
|
||||
return
|
||||
if command_name == '!banlist':
|
||||
await handle_ban_list_command(message, bot)
|
||||
return
|
||||
|
||||
if ConfigurationHelper().getValue('moderation_kick_enable'):
|
||||
if command_name == '!kick':
|
||||
await handle_kick_command(message, bot)
|
||||
return
|
||||
|
||||
if ConfigurationHelper().getValue('moderation_enable'):
|
||||
if command_name == '!inspect':
|
||||
await handle_inspect_command(message, bot)
|
||||
return
|
||||
|
||||
if command_name == '!say':
|
||||
await handle_say_command(message, bot)
|
||||
return
|
||||
|
||||
if command_name in ['!aide', '!help']:
|
||||
await handle_staff_help_command(message, bot)
|
||||
return
|
||||
|
||||
commande = Commande.query.filter_by(discord_enable=True, trigger=command_name).first()
|
||||
if commande:
|
||||
try:
|
||||
@@ -71,25 +164,116 @@ async def on_message(message: Message):
|
||||
except Exception as e:
|
||||
logging.error(f'Échec de l\'exécution de la commande Discord : {e}')
|
||||
|
||||
if(ConfigurationHelper().getValue('proton_db_enable_enable') and message.content.find('!protondb')==0) :
|
||||
if (ConfigurationHelper().getValue('proton_db_enable_enable') and (message.content.startswith('!protondb') or message.content.startswith('!pdb'))):
|
||||
if (message.content.find('<@')>0) :
|
||||
mention = message.content[message.content.find('<@'):]
|
||||
else :
|
||||
mention = message.author.mention
|
||||
name = message.content.replace('!protondb', '').replace(f'{mention}', '').strip();
|
||||
games = searhProtonDb(name)
|
||||
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}**...")
|
||||
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 ?'
|
||||
else :
|
||||
msg = f'{mention} J\'ai trouvé {len(games)} jeux :\n'
|
||||
ite = iter(games)
|
||||
while (game := next(ite, None)) is not None and len(msg) < 1850 :
|
||||
msg += f'- [{game.get('name')}](https://www.protondb.com/app/{game.get('id')}) classé **{game.get('tier')}**\n'
|
||||
rest = sum(1 for _ in ite)
|
||||
if (rest > 0):
|
||||
msg += f'- et encore {rest} autres jeux'
|
||||
try :
|
||||
await message.channel.send(msg, suppress_embeds=True)
|
||||
except Exception as e:
|
||||
logging.error(f'Échec de l\'envoi du message ProtonDB : {e}')
|
||||
try:
|
||||
await message.channel.send(msg, suppress_embeds=True)
|
||||
except Exception as e:
|
||||
logging.error(f"Échec de l'envoi du message ProtonDB : {e}")
|
||||
return
|
||||
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
|
||||
)
|
||||
|
||||
try :
|
||||
await message.channel.send(embed=embed)
|
||||
except Exception as e:
|
||||
logging.error(f"Échec de l'envoi de l'embed ProtonDB : {e}")
|
||||
|
||||
@bot.event
|
||||
async def on_member_join(member: Member):
|
||||
await sendWelcomeMessage(bot, member)
|
||||
|
||||
@bot.event
|
||||
async def on_member_remove(member: Member):
|
||||
await sendLeaveMessage(bot, member)
|
||||
|
||||
@bot.event
|
||||
async def on_invite_create(invite):
|
||||
await updateInviteCache(invite.guild)
|
||||
|
||||
@bot.event
|
||||
async def on_invite_delete(invite):
|
||||
await updateInviteCache(invite.guild)
|
||||
|
||||
|
||||
1407
discordbot/moderation.py
Normal file
1407
discordbot/moderation.py
Normal file
File diff suppressed because it is too large
Load Diff
198
discordbot/welcome.py
Normal file
198
discordbot/welcome.py
Normal file
@@ -0,0 +1,198 @@
|
||||
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}')
|
||||
|
||||
225
discordbot/youtube.py
Normal file
225
discordbot/youtube.py
Normal file
@@ -0,0 +1,225 @@
|
||||
import logging
|
||||
import asyncio
|
||||
import xml.etree.ElementTree as ET
|
||||
import requests
|
||||
|
||||
from database import db
|
||||
from database.models import YouTubeNotification
|
||||
from webapp import webapp
|
||||
|
||||
logger = logging.getLogger('youtube-notification')
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
async def checkYouTubeVideos():
|
||||
with webapp.app_context():
|
||||
try:
|
||||
notifications: list[YouTubeNotification] = YouTubeNotification.query.filter_by(enable=True).all()
|
||||
|
||||
for notification in notifications:
|
||||
try:
|
||||
await _checkChannelVideos(notification)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la vérification de la chaîne {notification.channel_id}: {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la vérification YouTube: {e}")
|
||||
|
||||
|
||||
async def _checkChannelVideos(notification: YouTubeNotification):
|
||||
try:
|
||||
channel_id = notification.channel_id
|
||||
|
||||
rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
||||
|
||||
response = await asyncio.to_thread(requests.get, rss_url, timeout=10)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Erreur HTTP {response.status_code} lors de la récupération du RSS pour {channel_id}")
|
||||
return
|
||||
|
||||
root = ET.fromstring(response.content)
|
||||
|
||||
ns = {'atom': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015', 'media': 'http://search.yahoo.com/mrss/'}
|
||||
|
||||
entries = root.findall('atom:entry', ns)
|
||||
|
||||
if not entries:
|
||||
logger.warning(f"Aucune vidéo trouvée dans le RSS pour {channel_id}")
|
||||
return
|
||||
|
||||
videos = []
|
||||
for entry in entries:
|
||||
video_id = entry.find('yt:videoId', ns)
|
||||
if video_id is None:
|
||||
continue
|
||||
video_id = video_id.text
|
||||
|
||||
title_elem = entry.find('atom:title', ns)
|
||||
video_title = title_elem.text if title_elem is not None else 'Sans titre'
|
||||
|
||||
link_elem = entry.find('atom:link', ns)
|
||||
video_url = link_elem.get('href') if link_elem is not None else f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
published_elem = entry.find('atom:published', ns)
|
||||
published_at = published_elem.text if published_elem is not None else ''
|
||||
|
||||
author_elem = entry.find('atom:author/atom:name', ns)
|
||||
channel_name = author_elem.text if author_elem is not None else 'Inconnu'
|
||||
|
||||
thumbnail = None
|
||||
media_thumbnail = entry.find('media:group/media:thumbnail', ns)
|
||||
if media_thumbnail is not None:
|
||||
thumbnail = media_thumbnail.get('url')
|
||||
|
||||
is_short = False
|
||||
if video_title and ('#shorts' in video_title.lower() or '#short' in video_title.lower()):
|
||||
is_short = True
|
||||
|
||||
if notification.video_type == 'all':
|
||||
videos.append((video_id, {
|
||||
'title': video_title,
|
||||
'url': video_url,
|
||||
'published': published_at,
|
||||
'channel_name': channel_name,
|
||||
'thumbnail': thumbnail,
|
||||
'is_short': is_short
|
||||
}))
|
||||
elif notification.video_type == 'short' and is_short:
|
||||
videos.append((video_id, {
|
||||
'title': video_title,
|
||||
'url': video_url,
|
||||
'published': published_at,
|
||||
'channel_name': channel_name,
|
||||
'thumbnail': thumbnail,
|
||||
'is_short': is_short
|
||||
}))
|
||||
elif notification.video_type == 'video' and not is_short:
|
||||
videos.append((video_id, {
|
||||
'title': video_title,
|
||||
'url': video_url,
|
||||
'published': published_at,
|
||||
'channel_name': channel_name,
|
||||
'thumbnail': thumbnail,
|
||||
'is_short': is_short
|
||||
}))
|
||||
|
||||
videos.sort(key=lambda x: x[1]['published'], reverse=True)
|
||||
|
||||
if videos:
|
||||
latest_video_id, latest_video = videos[0]
|
||||
|
||||
if not notification.last_video_id:
|
||||
notification.last_video_id = latest_video_id
|
||||
db.session.commit()
|
||||
return
|
||||
|
||||
if latest_video_id != notification.last_video_id:
|
||||
logger.info(f"Nouvelle vidéo détectée: {latest_video_id} pour la chaîne {notification.channel_id}")
|
||||
await _notifyVideo(notification, latest_video, latest_video_id)
|
||||
notification.last_video_id = latest_video_id
|
||||
db.session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la vérification des vidéos: {e}")
|
||||
|
||||
|
||||
async def _notifyVideo(notification: YouTubeNotification, video_data: dict, video_id: str):
|
||||
from discordbot import bot
|
||||
try:
|
||||
channel_name = video_data.get('channel_name', 'Inconnu')
|
||||
video_title = video_data.get('title', 'Sans titre')
|
||||
video_url = video_data.get('url', f"https://www.youtube.com/watch?v={video_id}")
|
||||
thumbnail = video_data.get('thumbnail', '')
|
||||
published_at = video_data.get('published', '')
|
||||
is_short = video_data.get('is_short', False)
|
||||
|
||||
try:
|
||||
message = notification.message.format(
|
||||
channel_name=channel_name or 'Inconnu',
|
||||
video_title=video_title or 'Sans titre',
|
||||
video_url=video_url,
|
||||
video_id=video_id,
|
||||
thumbnail=thumbnail or '',
|
||||
published_at=published_at or '',
|
||||
is_short=is_short
|
||||
)
|
||||
except KeyError as e:
|
||||
logger.error(f"Variable manquante dans le message de notification: {e}")
|
||||
message = f"🎥 Nouvelle vidéo de {channel_name}: [{video_title}]({video_url})"
|
||||
|
||||
logger.info(f"Envoi de notification YouTube: {message}")
|
||||
bot.loop.create_task(_sendMessage(notification, message, video_url, thumbnail, video_title, channel_name, video_id, published_at, is_short))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la notification: {e}")
|
||||
|
||||
|
||||
def _format_embed_text(text: str, channel_name: str, video_title: str, video_url: str, video_id: str, thumbnail: str, published_at: str, is_short: bool) -> str:
|
||||
"""Formate un texte d'embed avec les variables disponibles"""
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return text.format(
|
||||
channel_name=channel_name or 'Inconnu',
|
||||
video_title=video_title or 'Sans titre',
|
||||
video_url=video_url,
|
||||
video_id=video_id,
|
||||
thumbnail=thumbnail or '',
|
||||
published_at=published_at or '',
|
||||
is_short=is_short
|
||||
)
|
||||
except KeyError:
|
||||
return text
|
||||
|
||||
|
||||
async def _sendMessage(notification: YouTubeNotification, message: str, video_url: str, thumbnail: str, video_title: str, channel_name: str, video_id: str, published_at: str, is_short: bool):
|
||||
from discordbot import bot
|
||||
try:
|
||||
discord_channel = bot.get_channel(notification.notify_channel)
|
||||
if not discord_channel:
|
||||
logger.error(f"Canal Discord {notification.notify_channel} introuvable")
|
||||
return
|
||||
|
||||
import discord
|
||||
|
||||
embed_title = _format_embed_text(notification.embed_title, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if notification.embed_title else video_title
|
||||
embed_description = _format_embed_text(notification.embed_description, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if notification.embed_description else None
|
||||
|
||||
try:
|
||||
embed_color = int(notification.embed_color or 'FF0000', 16)
|
||||
except ValueError:
|
||||
embed_color = 0xFF0000
|
||||
|
||||
embed = discord.Embed(
|
||||
title=embed_title,
|
||||
url=video_url,
|
||||
color=embed_color
|
||||
)
|
||||
|
||||
if embed_description:
|
||||
embed.description = embed_description
|
||||
|
||||
author_name = _format_embed_text(notification.embed_author_name, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if notification.embed_author_name else channel_name
|
||||
author_icon = notification.embed_author_icon if notification.embed_author_icon else "https://www.youtube.com/img/desktop/yt_1200.png"
|
||||
embed.set_author(name=author_name, icon_url=author_icon)
|
||||
|
||||
if notification.embed_thumbnail and thumbnail:
|
||||
embed.set_thumbnail(url=thumbnail)
|
||||
|
||||
if notification.embed_image and thumbnail:
|
||||
embed.set_image(url=thumbnail)
|
||||
|
||||
if notification.embed_footer:
|
||||
footer_text = _format_embed_text(notification.embed_footer, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short)
|
||||
if footer_text:
|
||||
embed.set_footer(text=footer_text)
|
||||
|
||||
if message and message.strip():
|
||||
await discord_channel.send(message, embed=embed)
|
||||
else:
|
||||
await discord_channel.send(embed=embed)
|
||||
logger.info(f"Notification YouTube envoyée avec succès")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de l'envoi du message Discord: {e}")
|
||||
@@ -1,11 +1,11 @@
|
||||
services:
|
||||
mamiehenriette:
|
||||
container_name: MamieHenriette # Nom du conteneur
|
||||
image: ghcr.io/skylanix/mamiehenriette:latest # Image hébergée sur GitHub Container Registry
|
||||
restart: unless-stopped # Redémarre automatiquement sauf si arrêté manuellement
|
||||
|
||||
# build: . # Build du conteneur à partir d'un Dockerfile local (décommentez si nécessaire)
|
||||
# image: mamiehenriette # Build du conteneur à partir d'un Dockerfile local (décommentez si nécessaire)
|
||||
image: ghcr.io/skylanix/mamiehenriette:latest # Image hébergée sur GitHub Container Registry (commentez si nécessaire)
|
||||
|
||||
environment:
|
||||
TZ: Europe/Paris # Fuseau horaire
|
||||
@@ -45,4 +45,4 @@ services:
|
||||
# volumes:
|
||||
# - ./instance/database.db:/data/database.db # Monte la base de données locale dans le conteneur
|
||||
# environment:
|
||||
# - SQLITE_DATABASE=/data/database.db # Chemin vers la base de données dans le conteneur
|
||||
# - SQLITE_DATABASE=/data/database.db # Chemin vers la base de données dans le conteneur
|
||||
@@ -1,12 +1,14 @@
|
||||
|
||||
import logging
|
||||
import requests
|
||||
import re
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from algoliasearch.search.client import SearchClientSync, SearchConfig
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import GameAlias
|
||||
from sqlalchemy import desc,func
|
||||
from database.models import GameAlias, AntiCheatCache, Configuration
|
||||
from sqlalchemy import desc, func
|
||||
|
||||
def _call_algoliasearch(search_name:str):
|
||||
config = SearchConfig(ConfigurationHelper().getValue('proton_db_api_id'),
|
||||
@@ -37,9 +39,130 @@ def _apply_game_aliases(search_name:str) -> str:
|
||||
search_name = re.sub(re.escape(alias.alias), alias.name, search_name, flags=re.IGNORECASE)
|
||||
return search_name
|
||||
|
||||
def _should_update_anticheat_cache() -> bool:
|
||||
try:
|
||||
last_update_conf = Configuration.query.filter_by(key='anticheat_last_update').first()
|
||||
if not last_update_conf:
|
||||
return True
|
||||
try:
|
||||
last_update = datetime.fromisoformat(last_update_conf.value)
|
||||
return datetime.now() - last_update > timedelta(days=7)
|
||||
except:
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur lors de la vérification du cache anti-cheat: {e}')
|
||||
return False
|
||||
|
||||
def _fetch_anticheat_data():
|
||||
try:
|
||||
url = 'https://raw.githubusercontent.com/AreWeAntiCheatYet/AreWeAntiCheatYet/master/games.json'
|
||||
response = requests.get(url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
logging.error(f'Échec de la récupération des données anti-cheat. Code HTTP: {response.status_code}')
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur lors de la récupération des données anti-cheat: {e}')
|
||||
return None
|
||||
|
||||
def _update_anticheat_cache_if_needed():
|
||||
try:
|
||||
if not _should_update_anticheat_cache():
|
||||
return
|
||||
|
||||
logging.info('Mise à jour du cache anti-cheat...')
|
||||
anticheat_data = _fetch_anticheat_data()
|
||||
if not anticheat_data:
|
||||
return
|
||||
|
||||
for game in anticheat_data:
|
||||
try:
|
||||
steam_id = str(game.get('storeIds', {}).get('steam', ''))
|
||||
if not steam_id or steam_id == '0':
|
||||
continue
|
||||
|
||||
cache_entry = AntiCheatCache.query.filter_by(steam_id=steam_id).first()
|
||||
|
||||
status = game.get('status', 'Unknown')
|
||||
anticheats_list = game.get('anticheats', [])
|
||||
anticheats_str = json.dumps(anticheats_list) if anticheats_list else None
|
||||
reference = game.get('reference', '')
|
||||
notes_data = game.get('notes', '')
|
||||
if isinstance(notes_data, list):
|
||||
notes = json.dumps(notes_data)
|
||||
else:
|
||||
notes = str(notes_data) if notes_data else ''
|
||||
game_name = game.get('name', '')
|
||||
|
||||
if cache_entry:
|
||||
cache_entry.game_name = game_name
|
||||
cache_entry.status = status
|
||||
cache_entry.anticheats = anticheats_str
|
||||
cache_entry.reference = reference
|
||||
cache_entry.notes = notes
|
||||
cache_entry.updated_at = datetime.now()
|
||||
else:
|
||||
cache_entry = AntiCheatCache(
|
||||
steam_id=steam_id,
|
||||
game_name=game_name,
|
||||
status=status,
|
||||
anticheats=anticheats_str,
|
||||
reference=reference,
|
||||
notes=notes,
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
db.session.add(cache_entry)
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur lors de la mise à jour du jeu {game.get("name")}: {e}')
|
||||
continue
|
||||
|
||||
last_update_conf = Configuration.query.filter_by(key='anticheat_last_update').first()
|
||||
if last_update_conf:
|
||||
last_update_conf.value = datetime.now().isoformat()
|
||||
else:
|
||||
last_update_conf = Configuration(key='anticheat_last_update', value=datetime.now().isoformat())
|
||||
db.session.add(last_update_conf)
|
||||
|
||||
db.session.commit()
|
||||
logging.info('Cache anti-cheat mis à jour avec succès')
|
||||
except Exception as e:
|
||||
try:
|
||||
db.session.rollback()
|
||||
except:
|
||||
pass
|
||||
logging.error(f'Erreur lors de la mise à jour du cache anti-cheat: {e}')
|
||||
|
||||
def _get_anticheat_info(steam_id: str) -> dict:
|
||||
try:
|
||||
cache_entry = AntiCheatCache.query.filter_by(steam_id=steam_id).first()
|
||||
if not cache_entry:
|
||||
return None
|
||||
|
||||
try:
|
||||
anticheats = json.loads(cache_entry.anticheats) if cache_entry.anticheats else []
|
||||
except:
|
||||
anticheats = []
|
||||
|
||||
return {
|
||||
'status': cache_entry.status,
|
||||
'anticheats': anticheats,
|
||||
'reference': cache_entry.reference,
|
||||
'notes': cache_entry.notes
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur lors de la récupération des infos anti-cheat pour {steam_id}: {e}')
|
||||
return None
|
||||
|
||||
def searhProtonDb(search_name:str):
|
||||
results = []
|
||||
search_name = _apply_game_aliases(search_name)
|
||||
|
||||
try:
|
||||
_update_anticheat_cache_if_needed()
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur lors de la mise à jour du cache anti-cheat: {e}')
|
||||
|
||||
responses = _call_algoliasearch(search_name)
|
||||
for hit in responses.model_dump().get('hits'):
|
||||
id = hit.get('object_id')
|
||||
@@ -49,12 +172,27 @@ def searhProtonDb(search_name:str):
|
||||
summmary = _call_summary(id)
|
||||
if (summmary != None) :
|
||||
tier = summmary.get('tier')
|
||||
results.append({
|
||||
|
||||
anticheat_info = None
|
||||
try:
|
||||
anticheat_info = _get_anticheat_info(str(id))
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur lors de la récupération anti-cheat pour {name}: {e}')
|
||||
|
||||
result = {
|
||||
'id':id,
|
||||
'name' : name,
|
||||
'tier' : tier
|
||||
})
|
||||
logging.info(f'Trouvé {name}({id}) : {tier}')
|
||||
}
|
||||
|
||||
if anticheat_info:
|
||||
result['anticheat_status'] = anticheat_info.get('status')
|
||||
result['anticheats'] = anticheat_info.get('anticheats', [])
|
||||
result['anticheat_reference'] = anticheat_info.get('reference')
|
||||
result['anticheat_notes'] = anticheat_info.get('notes')
|
||||
|
||||
results.append(result)
|
||||
logging.info(f'Trouvé {name}({id}) : {tier}' + (f' [Anti-cheat: {anticheat_info.get("status")}]' if anticheat_info else ''))
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur lors du traitement du jeu {name} (ID: {id}) : {e}')
|
||||
else:
|
||||
|
||||
40
run-web.py
40
run-web.py
@@ -1,6 +1,8 @@
|
||||
import locale
|
||||
import logging
|
||||
import threading
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
from webapp import webapp
|
||||
from discordbot import bot
|
||||
@@ -23,12 +25,40 @@ def start_twitch_bot():
|
||||
twitchBot.begin()
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Config logs (console + fichier avec rotation)
|
||||
os.makedirs('logs', exist_ok=True)
|
||||
log_formatter = logging.Formatter('%(asctime)s %(levelname)s [%(threadName)s] %(name)s: %(message)s')
|
||||
handlers = []
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(log_formatter)
|
||||
handlers.append(stream_handler)
|
||||
file_handler = RotatingFileHandler('logs/app.log', maxBytes=5*1024*1024, backupCount=5, encoding='utf-8')
|
||||
file_handler.setFormatter(log_formatter)
|
||||
handlers.append(file_handler)
|
||||
logging.basicConfig(level=logging.INFO, handlers=handlers)
|
||||
|
||||
# Calmer les logs verbeux de certaines libs si besoin
|
||||
logging.getLogger('werkzeug').setLevel(logging.WARNING)
|
||||
logging.getLogger('discord').setLevel(logging.WARNING)
|
||||
|
||||
# Hook exceptions non-capturées (threads inclus)
|
||||
def _log_uncaught(exc_type, exc, tb):
|
||||
logging.exception('Exception non capturée', exc_info=(exc_type, exc, tb))
|
||||
import sys
|
||||
sys.excepthook = _log_uncaught
|
||||
if hasattr(threading, 'excepthook'):
|
||||
def _thread_excepthook(args):
|
||||
logging.exception(f"Exception dans le thread {args.thread.name}", exc_info=(args.exc_type, args.exc_value, args.exc_traceback))
|
||||
threading.excepthook = _thread_excepthook
|
||||
|
||||
locale.setlocale(locale.LC_TIME, 'fr_FR.UTF-8')
|
||||
|
||||
jobs = []
|
||||
jobs.append(threading.Thread(target=start_discord_bot))
|
||||
jobs.append(threading.Thread(target=start_server))
|
||||
jobs.append(threading.Thread(target=start_twitch_bot))
|
||||
jobs.append(threading.Thread(target=start_discord_bot, name='discord-bot'))
|
||||
jobs.append(threading.Thread(target=start_server, name='web-server'))
|
||||
jobs.append(threading.Thread(target=start_twitch_bot, name='twitch-bot'))
|
||||
|
||||
for job in jobs: job.start()
|
||||
for job in jobs: job.join()
|
||||
for job in jobs:
|
||||
job.start()
|
||||
for job in jobs:
|
||||
job.join()
|
||||
|
||||
@@ -14,8 +14,11 @@ USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT]
|
||||
|
||||
async def _onReady(ready_event: EventData):
|
||||
logging.info('Bot Twitch prêt')
|
||||
channel = ConfigurationHelper().getValue('twitch_channel')
|
||||
webapp.config["BOT_STATUS"]["twitch_connected"] = True
|
||||
webapp.config["BOT_STATUS"]["twitch_channel_name"] = channel
|
||||
with webapp.app_context():
|
||||
await ready_event.chat.join_room(ConfigurationHelper().getValue('twitch_channel'))
|
||||
await ready_event.chat.join_room(channel)
|
||||
asyncio.get_event_loop().create_task(twitchBot._checkOnlineStreamers())
|
||||
|
||||
|
||||
|
||||
@@ -2,4 +2,12 @@ from flask import Flask
|
||||
|
||||
webapp = Flask(__name__)
|
||||
|
||||
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth
|
||||
# État des bots (mis à jour par les bots, lu par le panneau)
|
||||
webapp.config["BOT_STATUS"] = {
|
||||
"discord_connected": False,
|
||||
"discord_guild_count": 0,
|
||||
"twitch_connected": False,
|
||||
"twitch_channel_name": None,
|
||||
}
|
||||
|
||||
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube
|
||||
|
||||
@@ -6,17 +6,37 @@ from discordbot import bot
|
||||
|
||||
@webapp.route("/configurations")
|
||||
def openConfigurations():
|
||||
return render_template("configurations.html", configuration = ConfigurationHelper(), channels = bot.getAllTextChannel())
|
||||
return render_template("configurations.html", configuration = ConfigurationHelper(), channels = bot.getAllTextChannel(), roles = bot.getAllRoles())
|
||||
|
||||
@webapp.route("/configurations/update", methods=['POST'])
|
||||
def updateConfiguration():
|
||||
for key in request.form :
|
||||
ConfigurationHelper().createOrUpdate(key, request.form.get(key))
|
||||
# Je fais ça car HTML n'envoie pas le paramètre de checkbox quand il est décoché
|
||||
if (request.form.get("humble_bundle_channel") != None and request.form.get("humble_bundle_enable") == None) :
|
||||
ConfigurationHelper().createOrUpdate('humble_bundle_enable', False)
|
||||
if (request.form.get("proton_db_api_id") != None and request.form.get("proton_db_enable_enable") == None) :
|
||||
ConfigurationHelper().createOrUpdate('proton_db_enable_enable', False)
|
||||
checkboxes = {
|
||||
'humble_bundle_enable': 'humble_bundle_channel',
|
||||
'proton_db_enable_enable': 'proton_db_api_id',
|
||||
'moderation_enable': 'moderation_staff_role_ids',
|
||||
'moderation_ban_enable': 'moderation_staff_role_ids',
|
||||
'moderation_kick_enable': 'moderation_staff_role_ids',
|
||||
'welcome_enable': 'welcome_channel_id',
|
||||
'leave_enable': 'leave_channel_id'
|
||||
}
|
||||
|
||||
staff_roles = request.form.getlist('moderation_staff_role_ids')
|
||||
if staff_roles:
|
||||
ConfigurationHelper().createOrUpdate('moderation_staff_role_ids', ','.join(staff_roles))
|
||||
else:
|
||||
ConfigurationHelper().createOrUpdate('moderation_staff_role_ids', '')
|
||||
|
||||
for key in request.form:
|
||||
if key == 'moderation_staff_role_ids':
|
||||
continue
|
||||
value = request.form.get(key)
|
||||
if value and value.strip():
|
||||
ConfigurationHelper().createOrUpdate(key, value)
|
||||
|
||||
for checkbox, reference_field in checkboxes.items():
|
||||
if request.form.get(reference_field) is not None and request.form.get(checkbox) is None:
|
||||
ConfigurationHelper().createOrUpdate(checkbox, False)
|
||||
|
||||
db.session.commit()
|
||||
return redirect(request.referrer)
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
from flask import render_template
|
||||
from webapp import webapp
|
||||
from database.models import ModerationEvent
|
||||
|
||||
@webapp.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
status = webapp.config["BOT_STATUS"]
|
||||
sanctions_count = ModerationEvent.query.count()
|
||||
return render_template(
|
||||
"index.html",
|
||||
discord_connected=status["discord_connected"],
|
||||
discord_guild_count=status["discord_guild_count"],
|
||||
sanctions_count=sanctions_count,
|
||||
twitch_connected=status["twitch_connected"],
|
||||
twitch_channel_name=status["twitch_channel_name"],
|
||||
)
|
||||
|
||||
72
webapp/moderation.py
Normal file
72
webapp/moderation.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from webapp import webapp
|
||||
from database import db
|
||||
from database.models import ModerationEvent
|
||||
|
||||
def _top_sanctioned():
|
||||
return (
|
||||
db.session.query(
|
||||
ModerationEvent.discord_id,
|
||||
db.func.max(ModerationEvent.username).label("username"),
|
||||
db.func.count(ModerationEvent.id).label("count"),
|
||||
)
|
||||
.group_by(ModerationEvent.discord_id)
|
||||
.order_by(db.func.count(ModerationEvent.id).desc())
|
||||
.limit(3)
|
||||
.all()
|
||||
)
|
||||
|
||||
def _top_moderators():
|
||||
return (
|
||||
db.session.query(
|
||||
ModerationEvent.staff_id,
|
||||
db.func.max(ModerationEvent.staff_name).label("staff_name"),
|
||||
db.func.count(ModerationEvent.id).label("count"),
|
||||
)
|
||||
.group_by(ModerationEvent.staff_id)
|
||||
.order_by(db.func.count(ModerationEvent.id).desc())
|
||||
.limit(3)
|
||||
.all()
|
||||
)
|
||||
|
||||
@webapp.route("/moderation")
|
||||
def moderation():
|
||||
events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
|
||||
top_sanctioned = _top_sanctioned()
|
||||
top_moderators = _top_moderators()
|
||||
return render_template(
|
||||
"moderation.html",
|
||||
events=events,
|
||||
event=None,
|
||||
top_sanctioned=top_sanctioned,
|
||||
top_moderators=top_moderators,
|
||||
)
|
||||
|
||||
@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()
|
||||
top_sanctioned = _top_sanctioned()
|
||||
top_moderators = _top_moderators()
|
||||
return render_template(
|
||||
"moderation.html",
|
||||
events=events,
|
||||
event=event,
|
||||
top_sanctioned=top_sanctioned,
|
||||
top_moderators=top_moderators,
|
||||
)
|
||||
|
||||
@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'))
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,55 +1,101 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<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>
|
||||
<th>Commande</th>
|
||||
<th>Réponse</th>
|
||||
<th>Discord</th>
|
||||
<th>Twitch</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for commande in commandes %}
|
||||
<tr>
|
||||
<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 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>
|
||||
|
||||
<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="discord_enable">Discord</label>
|
||||
<input name="discord_enable" type="checkbox" checked />
|
||||
<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">
|
||||
<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>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% 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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<label for="twitch_enable">Twitch</label>
|
||||
<input name="twitch_enable" type="checkbox" unchecked />
|
||||
</div>
|
||||
<input type="Submit" value="Ajouter">
|
||||
</form>
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
</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>
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,58 +1,251 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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="selected" {% endif %}>
|
||||
{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="Submit" value="Définir">
|
||||
</form>
|
||||
{% endblock %}
|
||||
<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>
|
||||
|
||||
<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">
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.id }}" {% if configuration.getIntValue('humble_bundle_channel') == channel.id %}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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,29 +1,44 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<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 %}
|
||||
<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 %}
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
|
||||
<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 %}
|
||||
<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">
|
||||
{% 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>
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,97 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<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 %}
|
||||
<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>
|
||||
|
||||
{# Zone Discord #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden mb-8">
|
||||
<div class="p-4 sm:p-6 border-b border-slate-200 dark:border-slate-700 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center justify-center w-3 h-3 rounded-full {% if discord_connected %}bg-emerald-500 ring-4 ring-emerald-500/30{% else %}bg-slate-400 ring-4 ring-slate-400/30{% endif %}" title="{% if discord_connected %}Bot Discord connecté{% else %}Bot Discord déconnecté{% endif %}"></span>
|
||||
<h2 class="text-xl font-semibold text-slate-800 dark:text-white">Discord</h2>
|
||||
</div>
|
||||
<span class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{% if discord_connected %}Connecté{% else %}Déconnecté{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-4 sm:p-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Serveurs connectés</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ discord_guild_count }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Sanctions enregistrées</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ sanctions_count }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600 sm:col-span-2 lg:col-span-2 flex items-center justify-center">
|
||||
<div class="flex flex-wrap gap-3 justify-center">
|
||||
<a href="/live-alert" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Alertes Live</a>
|
||||
<a href="/youtube" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Notification YouTube</a>
|
||||
<a href="/humeurs" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Humeurs</a>
|
||||
<a href="/protondb" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">ProtonDB</a>
|
||||
<a href="/commandes" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Commandes</a>
|
||||
<a href="/moderation" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Modération</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Zone Twitch #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden mb-8">
|
||||
<div class="p-4 sm:p-6 border-b border-slate-200 dark:border-slate-700 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center justify-center w-3 h-3 rounded-full {% if twitch_connected %}bg-emerald-500 ring-4 ring-emerald-500/30{% else %}bg-slate-400 ring-4 ring-slate-400/30{% endif %}" title="{% if twitch_connected %}Bot Twitch connecté{% else %}Bot Twitch déconnecté{% endif %}"></span>
|
||||
<h2 class="text-xl font-semibold text-slate-800 dark:text-white">Twitch</h2>
|
||||
</div>
|
||||
<span class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{% if twitch_connected %}Connecté{% else %}Déconnecté{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-4 sm:p-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Canal connecté</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{% if twitch_channel_name %}{{ twitch_channel_name }}{% else %}—{% endif %}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Sanctions</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">—</p>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">À venir</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600 sm:col-span-2 lg:col-span-2 flex items-center justify-center">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">Intégrations du bot Twitch à venir.</p>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,76 +1,135 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{% if not alert %}
|
||||
<h2>Alertes</h2>
|
||||
<table class="live-alert">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chaine</th>
|
||||
<th>Canal</th>
|
||||
<th>Message</th>
|
||||
<th>#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for alert in alerts %}
|
||||
<tr>
|
||||
<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 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">
|
||||
<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>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% 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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<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="selected" {% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<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>
|
||||
|
||||
{% endblock %}
|
||||
<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">
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.id }}" {% if alert and alert.notify_channel == channel.id %}selected{% endif %}>{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 %}
|
||||
|
||||
215
webapp/templates/moderation.html
Normal file
215
webapp/templates/moderation.html
Normal file
@@ -0,0 +1,215 @@
|
||||
{% 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="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<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">Top 3 sanctions</h2>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Utilisateurs les plus sanctionnés</p>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% for row in top_sanctioned %}
|
||||
<div class="px-5 py-3 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="flex-shrink-0 w-7 h-7 rounded-full bg-slate-200 dark:bg-slate-600 flex items-center justify-center text-sm font-bold text-slate-700 dark:text-slate-300">{{ loop.index }}</span>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-slate-800 dark:text-white truncate">{{ row.username or '—' }}</span>
|
||||
<span class="block text-xs text-slate-500 dark:text-slate-400 font-mono truncate">{{ row.discord_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="flex-shrink-0 text-sm font-semibold text-slate-600 dark:text-slate-300">{{ row.count }} sanction{{ 's' if row.count > 1 else '' }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-5 py-6 text-center text-sm text-slate-500 dark:text-slate-400">Aucune sanction enregistrée</div>
|
||||
{% endfor %}
|
||||
</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">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Top 3 modérateurs</h2>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Staff ayant effectué le plus d'actions</p>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% for row in top_moderators %}
|
||||
<div class="px-5 py-3 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="flex-shrink-0 w-7 h-7 rounded-full bg-slate-200 dark:bg-slate-600 flex items-center justify-center text-sm font-bold text-slate-700 dark:text-slate-300">{{ loop.index }}</span>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-slate-800 dark:text-white truncate">{{ row.staff_name or '—' }}</span>
|
||||
<span class="block text-xs text-slate-500 dark:text-slate-400 font-mono truncate">{{ row.staff_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="flex-shrink-0 text-sm font-semibold text-slate-600 dark:text-slate-300">{{ row.count }} action{{ 's' if row.count > 1 else '' }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-5 py-6 text-center text-sm text-slate-500 dark:text-slate-400">Aucune action enregistrée</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</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 %}
|
||||
@@ -1,61 +1,124 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Proton DB</h1>
|
||||
<p>ProtonDB évalue la compatibilité des jeux Windows sur Linux via Steam Play.</p>
|
||||
<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>
|
||||
|
||||
{% if configuration.getValue('proton_db_enable_enable') %}
|
||||
<h2>Game alias</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Alias</th>
|
||||
<th>Game</th>
|
||||
<th>#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in aliases %}
|
||||
<tr>
|
||||
<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 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">
|
||||
<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>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% 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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</form>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{% endblock %}
|
||||
<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 %}
|
||||
|
||||
@@ -1,38 +1,249 @@
|
||||
<!DOCTYPE html>
|
||||
<html color-mode="user">
|
||||
<html lang="fr" class="h-full">
|
||||
|
||||
<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, maximum-scale=1.0, user-scalable=0" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>Mamie Henriette</title>
|
||||
<link rel="stylesheet" href="/static/css/mvp.css" />
|
||||
<link rel="stylesheet" href="/static/css/style.css" />
|
||||
<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="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>
|
||||
<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>
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
<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">
|
||||
<!-- Discord (sous-menus) -->
|
||||
<div class="relative group">
|
||||
<button type="button" 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 flex items-center gap-1.5">
|
||||
<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"/></svg>
|
||||
Discord
|
||||
<svg class="w-4 h-4 transition-transform group-hover:rotate-180" 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>
|
||||
</button>
|
||||
<div class="absolute left-0 top-full pt-1 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 min-w-[200px]">
|
||||
<a href="/humeurs" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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>
|
||||
Humeur
|
||||
</a>
|
||||
<a href="/live-alert" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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>
|
||||
Notification Twitch
|
||||
</a>
|
||||
<a href="/youtube" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
||||
Notification YouTube
|
||||
</a>
|
||||
<a href="/protondb" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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
|
||||
</a>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
|
||||
<a href="/commandes" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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
|
||||
</a>
|
||||
<a href="/moderation" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Twitch (futur bot) -->
|
||||
<div class="relative group">
|
||||
<button type="button" 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 flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57l-.002-5.143zm3.43 0H16.714v5.143H15V4.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0H6zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714v9.429z"/></svg>
|
||||
Twitch
|
||||
<svg class="w-4 h-4 transition-transform group-hover:rotate-180" 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>
|
||||
</button>
|
||||
<div class="absolute left-0 top-full pt-1 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 min-w-[200px]">
|
||||
<a href="/live-alert" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<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
|
||||
</a>
|
||||
<span class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-400 dark:text-gray-500 italic">
|
||||
Bot Twitch — à venir
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration locale -->
|
||||
<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 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>
|
||||
Configuration
|
||||
</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="/youtube" 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="currentColor" viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
||||
YouTube
|
||||
</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>
|
||||
</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">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<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 -->
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</html>
|
||||
|
||||
@@ -1,35 +1,92 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<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="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>
|
||||
|
||||
<img src="/static/img/twitch-api-01.jpg">
|
||||
<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>
|
||||
|
||||
<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-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>
|
||||
|
||||
<img src="/static/img/twitch-api-02.jpg">
|
||||
<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>
|
||||
|
||||
<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="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>
|
||||
|
||||
{% endblock %}
|
||||
<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 %}
|
||||
|
||||
245
webapp/templates/youtube.html
Normal file
245
webapp/templates/youtube.html
Normal file
@@ -0,0 +1,245 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Notifications YouTube</h1>
|
||||
|
||||
{% if msg %}
|
||||
<div id="alert-msg" class="alert alert-{{ msg_type }}" style="padding: 10px; margin: 10px 0; border: 1px solid {{ '#f00' if msg_type == 'error' else '#0f0' }}; background-color: {{ '#ffe0e0' if msg_type == 'error' else '#e0ffe0' }};">
|
||||
{{ msg }}
|
||||
</div>
|
||||
<script>
|
||||
setTimeout(function() {
|
||||
var el = document.getElementById('alert-msg');
|
||||
if (el) el.style.display = 'none';
|
||||
}, 5000);
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
Liste des chaînes YouTube surveillées pour les notifications de nouvelles vidéos.
|
||||
|
||||
Le bot vérifie toutes les 5 minutes les nouvelles vidéos des chaînes en dessous.
|
||||
Quand une nouvelle vidéo est détectée, le bot enverra une notification sur Discord.
|
||||
</p>
|
||||
|
||||
{% if not notification %}
|
||||
<h2>Notifications</h2>
|
||||
<table class="live-alert">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chaîne YouTube</th>
|
||||
<th>Canal Discord</th>
|
||||
<th>Type</th>
|
||||
<th>Message</th>
|
||||
<th>#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for notification in notifications %}
|
||||
<tr>
|
||||
<td>{{notification.channel_id}}</td>
|
||||
<td>{{notification.notify_channel_name}}</td>
|
||||
<td>
|
||||
{% if notification.video_type == 'all' %}
|
||||
Toutes
|
||||
{% elif notification.video_type == 'video' %}
|
||||
Vidéos uniquement
|
||||
{% elif notification.video_type == 'short' %}
|
||||
Shorts uniquement
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{notification.message}}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('toggleYouTube', id = notification.id) }}" class="icon">{{ '✅' if notification.enable else '❌' }}</a>
|
||||
<a href="{{ url_for('openEditYouTube', id = notification.id) }}" class="icon">✐</a>
|
||||
<a href="{{ url_for('delYouTube', id = notification.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette notification ?')" class="icon">🗑</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<h2>{{ 'Editer une notification' if notification else 'Ajouter une notification YouTube' }}</h2>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||
<div>
|
||||
<form id="youtube-form" action="{{ url_for('submitEditYouTube', id = notification.id) if notification else url_for('addYouTube') }}" method="POST">
|
||||
<fieldset>
|
||||
<legend>Configuration de base</legend>
|
||||
<label for="channel_id">Lien ou ID de la chaîne YouTube</label>
|
||||
<input name="channel_id" id="channel_id" type="text" maxlength="256" required="required" value="{{notification.channel_id if notification}}" placeholder="https://www.youtube.com/@513v3 ou https://www.youtube.com/channel/UC... ou UC..."/>
|
||||
|
||||
<label for="notify_channel">Canal de Notification Discord</label>
|
||||
<select name="notify_channel" id="notify_channel">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}"{% if notification and notification.notify_channel == channel.id %}
|
||||
selected="selected" {% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="video_type">Type de vidéo à notifier</label>
|
||||
<select name="video_type" id="video_type">
|
||||
<option value="all"{% if notification and notification.video_type == 'all' %} selected="selected" {% endif %}>Toutes (vidéos + shorts)</option>
|
||||
<option value="video"{% if notification and notification.video_type == 'video' %} selected="selected" {% endif %}>Vidéos uniquement</option>
|
||||
<option value="short"{% if notification and notification.video_type == 'short' %} selected="selected" {% endif %}>Shorts uniquement</option>
|
||||
</select>
|
||||
|
||||
<label for="message">Message (optionnel, envoyé avant l'embed)</label>
|
||||
<textarea name="message" id="message" rows="3" cols="50">{{notification.message if notification}}</textarea>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Personnalisation de l'embed Discord</legend>
|
||||
|
||||
<label for="embed_title">Titre de l'embed</label>
|
||||
<input name="embed_title" id="embed_title" type="text" maxlength="256" value="{{notification.embed_title if notification}}" placeholder="{video_title} (par défaut: titre de la vidéo)"/>
|
||||
<small>Variables: {video_title}, {channel_name}, {video_url}, {video_id}</small>
|
||||
|
||||
<label for="embed_description">Description de l'embed</label>
|
||||
<textarea name="embed_description" id="embed_description" rows="4" cols="50" placeholder="Description optionnelle de l'embed">{{notification.embed_description if notification}}</textarea>
|
||||
<small>Variables: {video_title}, {channel_name}, {video_url}, {published_at}, {is_short}</small>
|
||||
|
||||
<label for="embed_color">Couleur de l'embed (hexadécimal)</label>
|
||||
<input name="embed_color" id="embed_color" type="color" value="#{{notification.embed_color if notification else 'FF0000'}}" style="width: 100px; height: 40px;"/>
|
||||
<input type="text" id="embed_color_text" value="{{notification.embed_color if notification else 'FF0000'}}" placeholder="FF0000" style="width: 100px; margin-left: 10px;" maxlength="6"/>
|
||||
<small>Format: FF0000 (rouge YouTube par défaut)</small>
|
||||
|
||||
<label for="embed_author_name">Nom de l'auteur</label>
|
||||
<input name="embed_author_name" id="embed_author_name" type="text" maxlength="256" value="{{notification.embed_author_name if notification}}" placeholder="{channel_name} (par défaut: nom de la chaîne)"/>
|
||||
|
||||
<label for="embed_author_icon">Icône de l'auteur (URL)</label>
|
||||
<input name="embed_author_icon" id="embed_author_icon" type="text" maxlength="512" value="{{notification.embed_author_icon if notification}}" placeholder="https://www.youtube.com/img/desktop/yt_1200.png"/>
|
||||
|
||||
<label for="embed_footer">Pied de page</label>
|
||||
<input name="embed_footer" id="embed_footer" type="text" maxlength="2048" value="{{notification.embed_footer if notification}}" placeholder="Texte optionnel en bas de l'embed"/>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="embed_thumbnail" id="embed_thumbnail" {% if not notification or notification.embed_thumbnail %}checked="checked"{% endif %}>
|
||||
Afficher la miniature (thumbnail) en haut à droite
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="embed_image" id="embed_image" {% if not notification or notification.embed_image %}checked="checked"{% endif %}>
|
||||
Afficher l'image principale (image de la vidéo)
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<input type="Submit" value="{{ 'Modifier' if notification else 'Ajouter' }}">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Prévisualisation de l'embed Discord</h3>
|
||||
<div id="embed-preview" style="background-color: #2f3136; border-radius: 4px; padding: 16px; font-family: 'Whitney', 'Helvetica Neue', Helvetica, Arial, sans-serif; color: #dcddde; max-width: 520px; border-left: 4px solid #FF0000;">
|
||||
<div id="embed-author" style="display: flex; align-items: center; margin-bottom: 8px; font-size: 14px;">
|
||||
<img id="embed-author-icon" src="https://www.youtube.com/img/desktop/yt_1200.png" style="width: 20px; height: 20px; border-radius: 50%; margin-right: 8px;" onerror="this.style.display='none'"/>
|
||||
<span id="embed-author-name" style="font-weight: 600;">Nom de la chaîne</span>
|
||||
</div>
|
||||
<a id="embed-title" href="#" style="color: #00aff4; text-decoration: none; font-size: 16px; font-weight: 600; display: block; margin-bottom: 8px;">Titre de la vidéo</a>
|
||||
<div id="embed-description" style="font-size: 14px; line-height: 1.375; margin-bottom: 8px; color: #dcddde;"></div>
|
||||
<div id="embed-thumbnail-container" style="margin: 8px 0;">
|
||||
<img id="embed-thumbnail" src="" style="max-width: 80px; max-height: 80px; border-radius: 4px; float: right; margin-left: 16px; display: none;"/>
|
||||
</div>
|
||||
<div id="embed-image-container" style="margin-top: 16px;">
|
||||
<img id="embed-image" src="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" style="max-width: 100%; border-radius: 4px; display: none;"/>
|
||||
</div>
|
||||
<div id="embed-footer" style="margin-top: 8px; font-size: 12px; color: #72767d;"></div>
|
||||
</div>
|
||||
<small style="color: #666;">Cette prévisualisation est approximative. L'apparence réelle sur Discord peut varier légèrement.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function formatText(text, vars) {
|
||||
if (!text) return '';
|
||||
return text.replace(/\{(\w+)\}/g, function(match, key) {
|
||||
return vars[key] || match;
|
||||
});
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
const embedTitle = document.getElementById('embed_title').value || '{video_title}';
|
||||
const embedDescription = document.getElementById('embed_description').value || '';
|
||||
const embedColor = document.getElementById('embed_color_text').value || 'FF0000';
|
||||
const embedAuthorName = document.getElementById('embed_author_name').value || '{channel_name}';
|
||||
const embedAuthorIcon = document.getElementById('embed_author_icon').value || 'https://www.youtube.com/img/desktop/yt_1200.png';
|
||||
const embedFooter = document.getElementById('embed_footer').value || '';
|
||||
const embedThumbnail = document.getElementById('embed_thumbnail').checked;
|
||||
const embedImage = document.getElementById('embed_image').checked;
|
||||
|
||||
const vars = {
|
||||
video_title: 'Nouvelle vidéo de test',
|
||||
channel_name: 'Ma Chaîne YouTube',
|
||||
video_url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
video_id: 'dQw4w9WgXcQ',
|
||||
thumbnail: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg',
|
||||
published_at: '2026-01-25T12:00:00Z',
|
||||
is_short: false
|
||||
};
|
||||
|
||||
document.getElementById('embed-title').textContent = formatText(embedTitle, vars);
|
||||
document.getElementById('embed-title').href = vars.video_url;
|
||||
document.getElementById('embed-description').textContent = formatText(embedDescription, vars);
|
||||
document.getElementById('embed-author-name').textContent = formatText(embedAuthorName, vars);
|
||||
document.getElementById('embed-author-icon').src = embedAuthorIcon;
|
||||
document.getElementById('embed-footer').textContent = formatText(embedFooter, vars);
|
||||
|
||||
document.getElementById('embed-preview').style.borderLeftColor = '#' + embedColor;
|
||||
|
||||
if (embedThumbnail) {
|
||||
document.getElementById('embed-thumbnail').src = vars.thumbnail;
|
||||
document.getElementById('embed-thumbnail').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('embed-thumbnail').style.display = 'none';
|
||||
}
|
||||
|
||||
if (embedImage) {
|
||||
document.getElementById('embed-image').src = vars.thumbnail;
|
||||
document.getElementById('embed-image').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('embed-image').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('embed_color').addEventListener('input', function(e) {
|
||||
document.getElementById('embed_color_text').value = e.target.value.substring(1).toUpperCase();
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
document.getElementById('embed_color_text').addEventListener('input', function(e) {
|
||||
const val = e.target.value.replace(/[^0-9A-Fa-f]/g, '').substring(0, 6);
|
||||
e.target.value = val;
|
||||
if (val.length === 6) {
|
||||
document.getElementById('embed_color').value = '#' + val;
|
||||
updatePreview();
|
||||
}
|
||||
});
|
||||
|
||||
const formFields = ['embed_title', 'embed_description', 'embed_author_name', 'embed_author_icon', 'embed_footer', 'embed_thumbnail', 'embed_image'];
|
||||
formFields.forEach(field => {
|
||||
const el = document.getElementById(field);
|
||||
if (el) {
|
||||
el.addEventListener('input', updatePreview);
|
||||
el.addEventListener('change', updatePreview);
|
||||
}
|
||||
});
|
||||
|
||||
updatePreview();
|
||||
</script>
|
||||
|
||||
<p>
|
||||
<strong>Variables disponibles pour l'embed :</strong>
|
||||
<ul>
|
||||
<li><code>{channel_name}</code> : nom de la chaîne YouTube</li>
|
||||
<li><code>{video_title}</code> : titre de la vidéo</li>
|
||||
<li><code>{video_url}</code> : lien vers la vidéo</li>
|
||||
<li><code>{video_id}</code> : ID de la vidéo</li>
|
||||
<li><code>{thumbnail}</code> : URL de la miniature</li>
|
||||
<li><code>{published_at}</code> : date de publication</li>
|
||||
<li><code>{is_short}</code> : True si c'est un short, False sinon</li>
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
{% endblock %}
|
||||
181
webapp/youtube.py
Normal file
181
webapp/youtube.py
Normal file
@@ -0,0 +1,181 @@
|
||||
import re
|
||||
import requests
|
||||
from urllib.parse import urlencode
|
||||
from flask import render_template, request, redirect, url_for
|
||||
|
||||
from webapp import webapp
|
||||
from database import db
|
||||
from database.models import YouTubeNotification
|
||||
from discordbot import bot
|
||||
|
||||
|
||||
def extract_channel_id(channel_input: str) -> str:
|
||||
"""Extrait l'ID de la chaîne YouTube depuis différents formats"""
|
||||
if not channel_input:
|
||||
return None
|
||||
|
||||
channel_input = channel_input.strip()
|
||||
|
||||
if channel_input.startswith('UC') and len(channel_input) == 24:
|
||||
return channel_input
|
||||
|
||||
if '/channel/' in channel_input:
|
||||
match = re.search(r'/channel/([a-zA-Z0-9_-]{24})', channel_input)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
if '/c/' in channel_input or '/user/' in channel_input:
|
||||
parts = channel_input.split('/')
|
||||
for i, part in enumerate(parts):
|
||||
if part in ['c', 'user'] and i + 1 < len(parts):
|
||||
handle = parts[i + 1].split('?')[0].split('&')[0]
|
||||
channel_id = _get_channel_id_from_handle(handle)
|
||||
if channel_id:
|
||||
return channel_id
|
||||
|
||||
if '@' in channel_input:
|
||||
handle = re.search(r'@([a-zA-Z0-9_-]+)', channel_input)
|
||||
if handle:
|
||||
channel_id = _get_channel_id_from_handle(handle.group(1))
|
||||
if channel_id:
|
||||
return channel_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_channel_id_from_handle(handle: str) -> str:
|
||||
"""Récupère l'ID de la chaîne depuis un handle en utilisant le flux RSS"""
|
||||
try:
|
||||
url = f"https://www.youtube.com/@{handle}"
|
||||
response = requests.get(url, timeout=10, allow_redirects=True)
|
||||
|
||||
if response.status_code == 200:
|
||||
channel_id_match = re.search(r'"channelId":"([^"]{24})"', response.text)
|
||||
if channel_id_match:
|
||||
return channel_id_match.group(1)
|
||||
|
||||
canonical_match = re.search(r'<link rel="canonical" href="https://www\.youtube\.com/channel/([^"]{24})"', response.text)
|
||||
if canonical_match:
|
||||
return canonical_match.group(1)
|
||||
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@webapp.route("/youtube")
|
||||
def openYouTube():
|
||||
notifications: list[YouTubeNotification] = YouTubeNotification.query.all()
|
||||
channels = bot.getAllTextChannel()
|
||||
for notification in notifications:
|
||||
for channel in channels:
|
||||
if notification.notify_channel == channel.id:
|
||||
notification.notify_channel_name = channel.name
|
||||
msg = request.args.get('msg')
|
||||
msg_type = request.args.get('type', 'info')
|
||||
return render_template("youtube.html", notifications=notifications, channels=channels, msg=msg, msg_type=msg_type)
|
||||
|
||||
|
||||
@webapp.route("/youtube/add", methods=['POST'])
|
||||
def addYouTube():
|
||||
channel_input = request.form.get('channel_id', '').strip()
|
||||
channel_id = extract_channel_id(channel_input)
|
||||
|
||||
if not channel_id:
|
||||
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': f"Impossible d'extraire l'ID de la chaîne depuis : {channel_input}. Veuillez vérifier le lien.", 'type': 'error'}))
|
||||
|
||||
notify_channel_str = request.form.get('notify_channel')
|
||||
if not notify_channel_str:
|
||||
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': "Veuillez sélectionner un canal Discord. Assurez-vous que le bot Discord est connecté.", 'type': 'error'}))
|
||||
|
||||
try:
|
||||
notify_channel = int(notify_channel_str)
|
||||
except ValueError:
|
||||
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': "Canal Discord invalide.", 'type': 'error'}))
|
||||
|
||||
embed_color = request.form.get('embed_color', 'FF0000').strip().lstrip('#')
|
||||
if len(embed_color) != 6:
|
||||
embed_color = 'FF0000'
|
||||
|
||||
notification = YouTubeNotification(
|
||||
enable=True,
|
||||
channel_id=channel_id,
|
||||
notify_channel=notify_channel,
|
||||
message=request.form.get('message'),
|
||||
video_type=request.form.get('video_type', 'all'),
|
||||
embed_title=request.form.get('embed_title') or None,
|
||||
embed_description=request.form.get('embed_description') or None,
|
||||
embed_color=embed_color,
|
||||
embed_footer=request.form.get('embed_footer') or None,
|
||||
embed_author_name=request.form.get('embed_author_name') or None,
|
||||
embed_author_icon=request.form.get('embed_author_icon') or None,
|
||||
embed_thumbnail=request.form.get('embed_thumbnail') == 'on',
|
||||
embed_image=request.form.get('embed_image') == 'on'
|
||||
)
|
||||
db.session.add(notification)
|
||||
db.session.commit()
|
||||
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': f"Notification ajoutée avec succès pour la chaîne {channel_id}", 'type': 'success'}))
|
||||
|
||||
|
||||
@webapp.route("/youtube/toggle/<int:id>")
|
||||
def toggleYouTube(id):
|
||||
notification: YouTubeNotification = YouTubeNotification.query.get_or_404(id)
|
||||
notification.enable = not notification.enable
|
||||
db.session.commit()
|
||||
return redirect(url_for("openYouTube"))
|
||||
|
||||
|
||||
@webapp.route("/youtube/edit/<int:id>")
|
||||
def openEditYouTube(id):
|
||||
notification = YouTubeNotification.query.get_or_404(id)
|
||||
channels = bot.getAllTextChannel()
|
||||
msg = request.args.get('msg')
|
||||
msg_type = request.args.get('type', 'info')
|
||||
return render_template("youtube.html", notification=notification, channels=channels, notifications=YouTubeNotification.query.all(), msg=msg, msg_type=msg_type)
|
||||
|
||||
|
||||
@webapp.route("/youtube/edit/<int:id>", methods=['POST'])
|
||||
def submitEditYouTube(id):
|
||||
notification: YouTubeNotification = YouTubeNotification.query.get_or_404(id)
|
||||
|
||||
channel_input = request.form.get('channel_id', '').strip()
|
||||
channel_id = extract_channel_id(channel_input)
|
||||
|
||||
if not channel_id:
|
||||
return redirect(url_for("openEditYouTube", id=id) + "?" + urlencode({'msg': f"Impossible d'extraire l'ID de la chaîne depuis : {channel_input}. Veuillez vérifier le lien.", 'type': 'error'}))
|
||||
|
||||
notify_channel_str = request.form.get('notify_channel')
|
||||
if not notify_channel_str:
|
||||
return redirect(url_for("openEditYouTube", id=id) + "?" + urlencode({'msg': "Veuillez sélectionner un canal Discord. Assurez-vous que le bot Discord est connecté.", 'type': 'error'}))
|
||||
|
||||
try:
|
||||
notify_channel = int(notify_channel_str)
|
||||
except ValueError:
|
||||
return redirect(url_for("openEditYouTube", id=id) + "?" + urlencode({'msg': "Canal Discord invalide.", 'type': 'error'}))
|
||||
|
||||
embed_color = request.form.get('embed_color', 'FF0000').strip().lstrip('#')
|
||||
if len(embed_color) != 6:
|
||||
embed_color = 'FF0000'
|
||||
|
||||
notification.channel_id = channel_id
|
||||
notification.notify_channel = notify_channel
|
||||
notification.message = request.form.get('message')
|
||||
notification.video_type = request.form.get('video_type', 'all')
|
||||
notification.embed_title = request.form.get('embed_title') or None
|
||||
notification.embed_description = request.form.get('embed_description') or None
|
||||
notification.embed_color = embed_color
|
||||
notification.embed_footer = request.form.get('embed_footer') or None
|
||||
notification.embed_author_name = request.form.get('embed_author_name') or None
|
||||
notification.embed_author_icon = request.form.get('embed_author_icon') or None
|
||||
notification.embed_thumbnail = request.form.get('embed_thumbnail') == 'on'
|
||||
notification.embed_image = request.form.get('embed_image') == 'on'
|
||||
db.session.commit()
|
||||
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': "Notification modifiée avec succès", 'type': 'success'}))
|
||||
|
||||
|
||||
@webapp.route("/youtube/del/<int:id>")
|
||||
def delYouTube(id):
|
||||
notification = YouTubeNotification.query.get_or_404(id)
|
||||
db.session.delete(notification)
|
||||
db.session.commit()
|
||||
return redirect(url_for("openYouTube"))
|
||||
Reference in New Issue
Block a user