Author SHA1 Message Date
Mow910 14c2cf0144 Refactor voice channel naming and access mode messaging. Removed emoji updates in channel names to prevent rate limiting, and streamlined access mode confirmation messages. Added logging for potential errors during channel renaming. 2026-02-17 18:25:48 +01:00
Mow910 d24be973b8 Amélioration de la gestion des permissions dans les salons vocaux automatiques. Préservation des permissions existantes pour le rôle "everyone" lors de l'application des modes d'accès, et mise à jour des overwrites pour les membres en fonction des listes blanche et noire. Cela permet une gestion plus fine des accès selon le mode sélectionné. 2026-02-17 17:56:07 +01:00
Mow910 5e4af406ff Ajout de la gestion des messages dans les salons automatiques avec la fonction on_message_auto_rooms. Mise à jour des emojis pour les modes d'accès et amélioration de l'embed de configuration des salons vocaux, incluant des sections pour les listes blanche et noire, ainsi que des informations sur le propriétaire et les modes d'accès. Correction de messages d'erreur et ajustements divers pour une meilleure expérience utilisateur. 2026-02-17 17:42:51 +01:00
Mow910 4d8038cc77 Fix: Corriger l'endpoint url_for dans youtube.html 2026-02-17 15:05:05 +01:00
Mow910 651773e63d Ajout de la sélection de tags pour les posts dans les forums lors du transfert de messages. Mise à jour de la classe TransferReasonModal pour inclure les tags sélectionnés et ajout d'une nouvelle classe ForumTagSelect pour gérer la sélection des tags. Amélioration de l'interaction utilisateur avec des messages contextuels pour la sélection des tags. 2026-02-14 20:15:08 +01:00
Mow910 60b90edcb3 Ajout de la commande contextuelle "Déplacer le message" pour transférer des messages entre canaux, avec gestion des forums et des threads. Intégration d'un modal pour spécifier la raison du transfert et mise à jour de la synchronisation des commandes d'application. 2026-02-14 19:36:39 +01:00
Mow910 179876d2ce Raison pour le titre du post dans le forum 2026-02-14 18:32:32 +01:00
Mow910 830ce61796 sans com 2026-02-14 18:28:03 +01:00
Mow910 5709b1c0a3 Amélioration de la commande !transfert pour supporter les canaux textuels, threads et forums. Ajout de vérifications de compatibilité pour le canal de destination et mise à jour de la documentation des commandes. Gestion des transferts de messages avec création de posts dans les forums et envoi via webhooks pour les autres types de canaux. 2026-02-14 18:01:31 +01:00
Mow910 11348bda39 Ajout de la commande !transfert pour transférer des messages entre canaux tout en préservant l'identité de l'auteur. Mise à jour de la documentation des commandes pour inclure cette nouvelle fonctionnalité et ses alias (!transfer, !move). 2026-02-14 16:55:52 +01:00
Mow910 f7e85bac69 Merge pull request #3 from Mow910/fix/twitch-auth-asyncio
Fix: Retrait de asyncio.run() pour le constructeur Twitch
2026-02-12 23:49:21 +01:00
Mow910andCursor d1d4e3b5a5 Fix: Retrait de asyncio.run() pour le constructeur Twitch
Le constructeur de la classe Twitch n'est pas une coroutine et ne doit pas être appelé avec asyncio.run(). Cela causait une ValueError lors de la demande de token Twitch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 23:47:50 +01:00
Mow910andCursor 5aa52c8137 Merge branch 'twitchbot' into main
Intégration de toutes les fonctionnalités développées dans la branche twitchbot :
- Système de modération Twitch complet
- Filtre de liens intelligent
- Notifications d'événements Twitch
- Système Freeloot Discord
- Salons automatiques Discord
- Authentification utilisateur
- Gestion des utilisateurs
- Interfaces d'administration étendues

Conflits résolus en faveur des versions de twitchbot pour :
- templates/commandes.html
- templates/configurations.html
- templates/humeurs.html
- templates/index.html
- templates/live-alert.html
- templates/moderation.html
- templates/protondb.html
- templates/template.html

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 22:40:27 +01:00
Mow910andCursor b274dcaa51 Ajout de fonctionnalités majeures Twitch, Discord et interface web
Nouvelles fonctionnalités :
- Système de modération Twitch complet (bans, timeouts, avertissements)
- Filtre de liens intelligent pour Twitch avec whitelist/blacklist
- Notifications d'événements Twitch (follows, subs, raids, etc.)
- Système Freeloot Discord avec flux RSS dédié
- Salons automatiques Discord (création/suppression dynamique)
- Authentification utilisateur pour l'interface web (login/register)
- Gestion des utilisateurs et permissions
- Interface de modération Twitch dans la webapp
- Interface de gestion des événements Twitch
- Configuration des paramètres utilisateur
- Migration BDD pour les mots bannis

Améliorations de l'interface :
- Refonte complète des templates (configurations, commandes, humeurs, etc.)
- Nouvelle page de settings utilisateur
- Page de gestion des utilisateurs (admin)
- Page d'erreur 403 personnalisée
- Amélioration de la navigation et du design global
- Intégration de nouvelles sections dans le menu principal

Modifications techniques :
- Ajout de nouveaux modèles en base de données
- Extension des helpers database
- Mise à jour des dépendances (requirements.txt)
- Amélioration de la gestion des annonces Twitch
- Refactorisation du code pour meilleure maintenabilité

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 22:37:22 +01:00
Mow910 d941fdcf9c Annonce twitch 2026-02-01 13:38:48 +01:00
Mow910 48531690fd Accueil restructuré (zones Discord/Twitch, stats, indicateur connexion) + Top 3 sanctions/modérateurs + BOT_STATUS dans config Flask 2026-02-01 12:55:38 +01:00
Mow910 9bbfa1fade Merge pull request #42 from skylanix/front-tailwind
Supprime les fichiers CSS obsolètes et met à jour les templates HTML …
2026-02-01 08:59:03 +01:00
Mow910 920ddfa172 Supprime les fichiers CSS obsolètes et met à jour les templates HTML pour une interface utilisateur améliorée. Les modifications incluent la refonte des pages de configuration, d'alerte live, de modération, et d'aide Twitch, avec une mise en page responsive et des styles modernisés. Les anciennes feuilles de style MVP.css et style.css ont été supprimées au profit d'une intégration de Tailwind CSS. 2026-01-25 19:07:13 +01:00
Mow910 a8d2a0e063 Ajout de nouvelles colonnes pour la personnalisation des notifications YouTube dans la table youtube_notification, y compris le titre, la description, la couleur, le pied de page, le nom et l'icône de l'auteur, ainsi que des options pour afficher la miniature et l'image. Mise à jour de l'interface web pour permettre la configuration de ces options et ajout d'une prévisualisation de l'embed Discord. 2026-01-25 17:45:59 +01:00
Mow910 f2cd19a053 Ajout d'un système de notifications YouTube avec une nouvelle table youtube_notification dans la base de données, intégration de la vérification des vidéos YouTube, et création d'une interface web pour gérer les notifications. Le bot Discord enverra des alertes pour les nouvelles vidéos détectées. 2026-01-25 17:28:38 +01:00
Mow910 4973144e54 Merge pull request #40 from skylanix/welcome-warn
Ajout d'une réaction d'avertissement pour les nouveaux membres ayant …
2025-12-16 15:37:56 +01:00
Mow 9afd3b2588 Ajout d'une réaction d'avertissement pour les nouveaux membres ayant moins de 7 jours de compte lors de l'envoi du message de bienvenue. 2025-12-03 20:40:39 +01:00
68 changed files with 10011 additions and 5177 deletions
-2
View File
@@ -22,13 +22,11 @@ RUN sed -i 's/# fr_FR.UTF-8 UTF-8/fr_FR.UTF-8 UTF-8/' /etc/locale.gen; \
COPY requirements.txt .
COPY run-web.py .
COPY shared_stats.py .
COPY ./database ./database
COPY ./discordbot ./discordbot
COPY ./protondb ./protondb
COPY ./webapp ./webapp
COPY ./twitchbot ./twitchbot
COPY ./youtubebot ./youtubebot
COPY start.sh /start.sh
RUN python3 -m venv /app/venv && \
+228 -12
View File
@@ -33,7 +33,13 @@ def _set_sqlite_pragma(dbapi_connection, connection_record):
except Exception:
pass
def _tableHaveColumn(table_name:str, column_name:str, cursor:Cursor) -> bool:
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)
@@ -53,9 +59,9 @@ def _doPreImportMigration(cursor:Cursor):
_renameTable('game_bundle', 'game_bundle_old', cursor)
def _doPostImportMigration(cursor:Cursor):
if _tableEmpty('game_bundle', cursor) :
if _tableEmpty('game_bundle', cursor) and _tableExists('game_bundle_old', cursor):
logging.info("remplir game_bundle avec game_bundle_old")
bundles = cursor.execute(f'SELECT * FROM game_bundle_old').fetchall()
bundles = cursor.execute('SELECT * FROM game_bundle_old').fetchall()
for bundle in bundles :
name = bundle[1]
json_data = json.loads(bundle[2])
@@ -65,18 +71,228 @@ 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}")
if _tableExists('commande', cursor) and not _tableHaveColumn('commande', 'twitch_permission', cursor):
try:
cursor.execute("ALTER TABLE commande ADD COLUMN twitch_permission VARCHAR(16) DEFAULT 'viewer'")
logging.info("Colonne twitch_permission ajoutée à commande")
except Exception as e:
logging.warning(f"Colonne commande.twitch_permission: {e}")
def _doAddColumnMigrations(cursor: Cursor):
"""Migrations d'ajout de colonnes. Exécutées à part pour ne pas dépendre du script principal."""
if _tableExists('commande', cursor) and not _tableHaveColumn('commande', 'twitch_permission', cursor):
try:
cursor.execute("ALTER TABLE commande ADD COLUMN twitch_permission VARCHAR(16) DEFAULT 'viewer'")
logging.info("Colonne twitch_permission ajoutée à commande")
except Exception as e:
logging.warning(f"Colonne commande.twitch_permission: {e}")
if _tableExists('live_alert', cursor) and not _tableHaveColumn('live_alert', 'watch_activity', cursor):
try:
cursor.execute("ALTER TABLE live_alert ADD COLUMN watch_activity BOOLEAN NOT NULL DEFAULT 0")
logging.info("Colonne watch_activity ajoutée à live_alert")
except Exception as e:
logging.warning(f"Colonne live_alert.watch_activity: {e}")
# Colonnes embed pour live_alert (message par défaut en embed)
if _tableExists('live_alert', cursor):
live_alert_embed_columns = [
('embed_title', 'VARCHAR(256)'),
('embed_description', 'VARCHAR(2000)'),
('embed_color', 'VARCHAR(8) DEFAULT "9146FF"'),
('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 live_alert_embed_columns:
if not _tableHaveColumn('live_alert', col_name, cursor):
try:
cursor.execute(f'ALTER TABLE live_alert ADD COLUMN {col_name} {col_type}')
logging.info(f"Colonne {col_name} ajoutée à live_alert")
except Exception as e:
logging.warning(f"Colonne live_alert.{col_name}: {e}")
# Table twitch_event_notification + seed des 4 types
if not _tableExists('twitch_event_notification', cursor):
try:
cursor.execute("""
CREATE TABLE twitch_event_notification (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type VARCHAR(32) UNIQUE NOT NULL,
enable BOOLEAN NOT NULL DEFAULT 1,
notify_twitch_chat BOOLEAN NOT NULL DEFAULT 1,
notify_discord BOOLEAN NOT NULL DEFAULT 0,
discord_channel_id INTEGER NULL,
message_twitch VARCHAR(500) NOT NULL DEFAULT '',
message_discord VARCHAR(2000) NULL,
embed_color VARCHAR(8) DEFAULT '9146FF',
embed_title VARCHAR(256) NULL,
embed_description VARCHAR(2000) NULL,
embed_thumbnail BOOLEAN NOT NULL DEFAULT 1,
last_clip_id VARCHAR(128) NULL
)
""")
logging.info("Table twitch_event_notification créée")
except Exception as e:
logging.warning(f"Table twitch_event_notification: {e}")
if _tableExists('twitch_event_notification', cursor) and _tableEmpty('twitch_event_notification', cursor):
for ev in ('sub', 'follow', 'raid', 'clip'):
try:
cursor.execute(
"INSERT INTO twitch_event_notification (event_type, message_twitch) VALUES (?, ?)",
(ev, 'Merci {user} !' if ev != 'raid' else 'Bienvenue aux viewers de {from_broadcaster_name} !'),
)
except Exception as e:
logging.warning(f"Seed twitch_event_notification {ev}: {e}")
# Table webapp_user (auth)
if not _tableExists('webapp_user', cursor):
try:
cursor.execute("""
CREATE TABLE webapp_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(64) UNIQUE NOT NULL,
email VARCHAR(256) UNIQUE NOT NULL,
password_hash VARCHAR(256) NOT NULL,
role VARCHAR(64) NOT NULL DEFAULT 'viewer_twitch',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
logging.info("Table webapp_user créée")
except Exception as e:
logging.warning(f"Table webapp_user: {e}")
# Tables webapp_role et webapp_page_permission
if not _tableExists('webapp_role', cursor):
try:
cursor.execute("""
CREATE TABLE webapp_role (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(64) UNIQUE NOT NULL,
level INTEGER NOT NULL DEFAULT 0
)
""")
logging.info("Table webapp_role créée")
except Exception as e:
logging.warning(f"Table webapp_role: {e}")
if not _tableExists('webapp_page_permission', cursor):
try:
cursor.execute("""
CREATE TABLE webapp_page_permission (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_key VARCHAR(64) UNIQUE NOT NULL,
min_level INTEGER NOT NULL DEFAULT 0,
write_level INTEGER NULL
)
""")
logging.info("Table webapp_page_permission créée")
except Exception as e:
logging.warning(f"Table webapp_page_permission: {e}")
def _doSeedAuth(cursor: Cursor):
"""Seed rôles par défaut et permissions des pages si vides."""
from database.models import ROLE_ORDER
if not _tableExists('webapp_role', cursor):
return
if cursor.execute("SELECT COUNT(*) FROM webapp_role").fetchone()[0] > 0:
return
default_roles = [
("viewer_twitch", 0),
("utilisateur_discord", 1),
("moderateur_discord", 2),
("expert_discord", 3),
("moderateur_twitch", 4),
("super_administrateur", 5),
]
for name, level in default_roles:
try:
cursor.execute("INSERT INTO webapp_role (name, level) VALUES (?, ?)", (name, level))
except Exception as e:
logging.warning(f"Seed role {name}: {e}")
logging.info("Rôles par défaut insérés")
if not _tableExists('webapp_page_permission', cursor):
return
if cursor.execute("SELECT COUNT(*) FROM webapp_page_permission").fetchone()[0] > 0:
return
# page_key, min_level, write_level (NULL = même que min_level)
default_pages = [
("index", 0, None),
("configurations", 5, 5),
("commandes", 1, 2),
("humeurs", 1, 2),
("live_alert", 0, 4),
("announcements", 0, 4),
("twitch_moderation", 0, 4),
("link_filter", 0, 4),
("twitch_events", 0, 4),
("youtube", 1, 2),
("protondb", 1, 2),
("freeloot", 1, 2),
("moderation", 1, 2),
("users", 5, 5),
("settings", 5, 5),
]
for page_key, min_level, wl in default_pages:
try:
cursor.execute(
"INSERT INTO webapp_page_permission (page_key, min_level, write_level) VALUES (?, ?, ?)",
(page_key, min_level, wl),
)
except Exception as e:
logging.warning(f"Seed page {page_key}: {e}")
logging.info("Permissions pages par défaut insérées")
# Inscriptions activées par défaut
if _tableExists("configuration", cursor):
try:
cursor.execute(
"INSERT OR IGNORE INTO configuration (key, value) VALUES ('registration_enabled', 'true')"
)
except Exception as e:
logging.warning(f"Config registration_enabled: {e}")
with webapp.app_context():
with open('database/schema.sql', 'r') as f:
sql = f.read()
connection : Connection = db.session.connection().connection
connection: Connection = db.session.connection().connection
try:
cursor = connection.cursor()
_doPreImportMigration(cursor)
cursor.executescript(sql)
_doPostImportMigration(cursor)
connection.commit()
except Exception as e:
logging.error(f"lors de l'import de la bdd : {e}")
finally:
try:
cursor : Cursor = connection.cursor()
_doPreImportMigration(cursor)
cursor.executescript(sql)
_doPostImportMigration(cursor)
cursor = connection.cursor()
_doAddColumnMigrations(cursor)
_doSeedAuth(cursor)
connection.commit()
cursor.close()
except Exception as e:
logging.error(f"lors de l'import de la bdd : {e}")
finally:
connection.close()
logging.warning(f"migrations colonnes : {e}")
connection.close()
+11
View File
@@ -0,0 +1,11 @@
-- Migration: Ajout de la table twitch_banned_word
-- Date: 2026-02-10
-- Description: Permet de gérer les mots interdits dans le chat Twitch
CREATE TABLE IF NOT EXISTS `twitch_banned_word` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`word` VARCHAR(256) UNIQUE NOT NULL,
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
`timeout_duration` INTEGER NOT NULL DEFAULT 60,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+165 -43
View File
@@ -1,4 +1,62 @@
from datetime import datetime
from database import db
from flask_login import UserMixin
# Rôles par défaut (niveau 0 à 5) — seed en base via migration
ROLE_ORDER = [
"viewer_twitch",
"utilisateur_discord",
"moderateur_discord",
"expert_discord",
"moderateur_twitch",
"super_administrateur",
]
def role_level(role_name: str) -> int:
"""Retourne le niveau du rôle depuis la table webapp_role (-1 si inconnu)."""
if not role_name:
return -1
r = WebappRole.query.filter_by(name=role_name).first()
return r.level if r else -1
class WebappRole(db.Model):
__tablename__ = "webapp_role"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True, nullable=False)
level = db.Column(db.Integer, nullable=False, default=0)
description = db.Column(db.String(256), nullable=True)
color = db.Column(db.String(7), nullable=True, default="#6B7280") # Couleur hexadécimale pour l'affichage
icon = db.Column(db.String(32), nullable=True) # Nom d'icône (ex: 'user', 'shield', 'crown')
class PagePermission(db.Model):
__tablename__ = "webapp_page_permission"
id = db.Column(db.Integer, primary_key=True)
page_key = db.Column(db.String(64), unique=True, nullable=False)
min_level = db.Column(db.Integer, nullable=False, default=0)
write_level = db.Column(db.Integer, nullable=True)
category = db.Column(db.String(32), nullable=True, default="general") # Catégorie: 'general', 'moderation', 'content', 'config'
description = db.Column(db.String(256), nullable=True) # Description de la page
class WebappUser(db.Model, UserMixin):
__tablename__ = "webapp_user"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False)
email = db.Column(db.String(256), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
role = db.Column(db.String(64), nullable=False, default="viewer_twitch")
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
def get_level(self) -> int:
return role_level(self.role)
def has_role_at_least(self, min_role: str) -> bool:
return self.get_level() >= role_level(min_role)
def has_level_at_least(self, level: int) -> bool:
return self.get_level() >= level
def has_any_role(self, roles: list) -> bool:
return self.role in roles
class Configuration(db.Model):
key = db.Column(db.String(32), primary_key=True)
@@ -25,20 +83,36 @@ class LiveAlert(db.Model):
online = db.Column(db.Boolean, default=False)
login = db.Column(db.String(128))
notify_channel = db.Column(db.Integer)
message = db.Column(db.String(2000))
message = db.Column(db.String(2000)) # message optionnel avant l'embed
watch_activity = db.Column(db.Boolean, default=False)
# Personnalisation de l'embed Discord (comme YouTube)
embed_title = db.Column(db.String(256))
embed_description = db.Column(db.String(2000))
embed_color = db.Column(db.String(8), default='9146FF') # violet Twitch
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)
class Message(db.Model):
class TwitchAnnouncement(db.Model):
__tablename__ = 'twitch_announcement'
id = db.Column(db.Integer, primary_key=True)
enable = db.Column(db.Boolean, default=False)
text = db.Column(db.String(256))
periodicity = db.Column(db.Integer)
enable = db.Column(db.Boolean, default=True)
name = db.Column(db.String(64))
text = db.Column(db.String(500))
periodicity = db.Column(db.Integer, default=10)
min_chat_messages = db.Column(db.Integer, default=0)
last_sent = db.Column(db.DateTime, nullable=True)
# Niveaux de permission Twitch pour les commandes personnalisées : viewer, sub, vip, moderator
class Commande(db.Model):
id = db.Column(db.Integer, primary_key=True)
discord_enable = db.Column(db.Boolean, default=True)
twitch_enable = db.Column(db.Boolean, default=True)
trigger = db.Column(db.String(32), unique=True)
response = db.Column(db.String(2000))
twitch_permission = db.Column(db.String(16), default='viewer') # viewer | sub | vip | moderator
class ModerationEvent(db.Model):
id = db.Column(db.Integer, primary_key=True)
@@ -51,6 +125,46 @@ class ModerationEvent(db.Model):
staff_name = db.Column(db.String(256))
duration = db.Column(db.Integer)
class TwitchModerationLog(db.Model):
__tablename__ = 'twitch_moderation_log'
id = db.Column(db.Integer, primary_key=True)
action = db.Column(db.String(32))
moderator = db.Column(db.String(256))
target = db.Column(db.String(256))
details = db.Column(db.String(512))
created_at = db.Column(db.DateTime)
class TwitchLinkFilter(db.Model):
__tablename__ = 'twitch_link_filter'
id = db.Column(db.Integer, primary_key=True)
enabled = db.Column(db.Boolean, default=False)
allow_subscribers = db.Column(db.Boolean, default=True)
allow_vips = db.Column(db.Boolean, default=True)
allow_moderators = db.Column(db.Boolean, default=True)
timeout_duration = db.Column(db.Integer, default=60)
warning_message = db.Column(db.String(500), default="Les liens ne sont pas autorises dans le chat.")
class TwitchAllowedDomain(db.Model):
__tablename__ = 'twitch_allowed_domain'
id = db.Column(db.Integer, primary_key=True)
domain = db.Column(db.String(256), unique=True)
class TwitchPermit(db.Model):
__tablename__ = 'twitch_permit'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(256))
expires_at = db.Column(db.DateTime)
class TwitchAllowedUser(db.Model):
__tablename__ = 'twitch_allowed_user'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(256), unique=True)
class AntiCheatCache(db.Model):
__tablename__ = 'anticheat_cache'
steam_id = db.Column(db.String(32), primary_key=True)
@@ -61,47 +175,55 @@ class AntiCheatCache(db.Model):
notes = db.Column(db.String(1024))
updated_at = db.Column(db.DateTime)
class FreeGame(db.Model):
__tablename__ = 'free_game'
id = db.Column(db.Integer, primary_key=True)
entry_id = db.Column(db.String(512), unique=True)
title = db.Column(db.String(512))
source = db.Column(db.String(64))
url = db.Column(db.String(2048))
image_url = db.Column(db.String(2048))
description = db.Column(db.Text)
valid_from = db.Column(db.DateTime)
valid_to = db.Column(db.DateTime)
notified = db.Column(db.Boolean, default=False)
notified_at = db.Column(db.DateTime)
created_at = db.Column(db.DateTime)
class DiscordInvite(db.Model):
__tablename__ = 'discord_invite'
code = db.Column(db.String(32), primary_key=True)
guild_id = db.Column(db.String(64), nullable=False)
channel_id = db.Column(db.String(64), nullable=False)
channel_name = db.Column(db.String(256))
inviter_id = db.Column(db.String(64))
inviter_name = db.Column(db.String(256))
uses = db.Column(db.Integer, default=0)
max_uses = db.Column(db.Integer, default=0)
max_age = db.Column(db.Integer, default=0)
temporary = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime)
expires_at = db.Column(db.DateTime)
revoked = db.Column(db.Boolean, default=False)
last_sync = db.Column(db.DateTime)
class YoutubeAlert(db.Model):
__tablename__ = 'youtube_alert'
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(64), unique=True)
channel_name = db.Column(db.String(256))
channel_id = db.Column(db.String(128))
notify_channel = db.Column(db.Integer)
message = db.Column(db.String(2000))
last_video_id = db.Column(db.String(64))
last_video_title = db.Column(db.String(512))
last_video_date = db.Column(db.DateTime)
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)
class FreeLootEntry(db.Model):
__tablename__ = 'freeloot_entry'
entry_id = db.Column(db.String(256), primary_key=True)
class TwitchEventNotification(db.Model):
"""Configuration des notifications par type d'événement (sub, follow, raid, clip)."""
__tablename__ = 'twitch_event_notification'
id = db.Column(db.Integer, primary_key=True)
event_type = db.Column(db.String(32), unique=True, nullable=False) # sub, follow, raid, clip
enable = db.Column(db.Boolean, default=True)
notify_twitch_chat = db.Column(db.Boolean, default=True)
notify_discord = db.Column(db.Boolean, default=False)
discord_channel_id = db.Column(db.Integer, nullable=True)
message_twitch = db.Column(db.String(500), default='')
message_discord = db.Column(db.String(2000), nullable=True)
embed_color = db.Column(db.String(8), default='9146FF')
embed_title = db.Column(db.String(256), nullable=True)
embed_description = db.Column(db.String(2000), nullable=True)
embed_thumbnail = db.Column(db.Boolean, default=True)
last_clip_id = db.Column(db.String(128), nullable=True) # pour détecter les nouveaux clips
class TwitchBannedWord(db.Model):
"""Mots interdits dans le chat Twitch."""
__tablename__ = 'twitch_banned_word'
id = db.Column(db.Integer, primary_key=True)
word = db.Column(db.String(256), unique=True, nullable=False)
enabled = db.Column(db.Boolean, default=True)
timeout_duration = db.Column(db.Integer, default=60) # durée du timeout en secondes
created_at = db.Column(db.DateTime, default=datetime.utcnow)
+117 -39
View File
@@ -28,14 +28,26 @@ CREATE TABLE IF NOT EXISTS live_alert (
`online` BOOLEAN NOT NULL DEFAULT FALSE,
`login` VARCHAR(128) UNIQUE NOT NULL,
`notify_channel` INTEGER NOT NULL,
`message` VARCHAR(2000) NOT NULL
`message` VARCHAR(2000),
`watch_activity` BOOLEAN NOT NULL DEFAULT FALSE,
`embed_title` VARCHAR(256),
`embed_description` VARCHAR(2000),
`embed_color` VARCHAR(8) DEFAULT '9146FF',
`embed_footer` VARCHAR(2048),
`embed_author_name` VARCHAR(256),
`embed_author_icon` VARCHAR(512),
`embed_thumbnail` BOOLEAN DEFAULT TRUE,
`embed_image` BOOLEAN DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS `message` (
CREATE TABLE IF NOT EXISTS `twitch_announcement` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`enable` BOOLEAN NOT NULL DEFAULT FALSE,
`text` VARCHAR(256) NULL,
periodicity INTEGER NULL
`enable` BOOLEAN NOT NULL DEFAULT TRUE,
`name` VARCHAR(64) NOT NULL,
`text` VARCHAR(500) NOT NULL,
`periodicity` INTEGER NOT NULL DEFAULT 10,
`min_chat_messages` INTEGER NOT NULL DEFAULT 0,
`last_sent` DATETIME NULL
);
CREATE TABLE IF NOT EXISTS `commande` (
@@ -43,7 +55,8 @@ CREATE TABLE IF NOT EXISTS `commande` (
`discord_enable` BOOLEAN NOT NULL DEFAULT TRUE,
`twitch_enable` BOOLEAN NOT NULL DEFAULT TRUE,
`trigger` VARCHAR(16) UNIQUE NOT NULL,
`response` VARCHAR(2000) NOT NULL
`response` VARCHAR(2000) NOT NULL,
`twitch_permission` VARCHAR(16) DEFAULT 'viewer'
);
CREATE TABLE IF NOT EXISTS `moderation_event` (
@@ -58,6 +71,15 @@ CREATE TABLE IF NOT EXISTS `moderation_event` (
`duration` INTEGER NULL
);
CREATE TABLE IF NOT EXISTS `twitch_moderation_log` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`action` VARCHAR(32) NOT NULL,
`moderator` VARCHAR(256) NOT NULL,
`target` VARCHAR(256),
`details` VARCHAR(512),
`created_at` DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS `anticheat_cache` (
steam_id VARCHAR(32) PRIMARY KEY,
game_name VARCHAR(256) NOT NULL,
@@ -77,46 +99,102 @@ CREATE TABLE IF NOT EXISTS `member_invites` (
`join_date` DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS `free_game` (
CREATE TABLE IF NOT EXISTS `twitch_link_filter` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`entry_id` VARCHAR(512) UNIQUE NOT NULL,
`title` VARCHAR(512) NOT NULL,
`source` VARCHAR(64) NOT NULL,
`url` VARCHAR(2048) NOT NULL,
`image_url` VARCHAR(2048),
`description` TEXT,
`valid_from` DATETIME,
`valid_to` DATETIME,
`notified` BOOLEAN NOT NULL DEFAULT FALSE,
`notified_at` DATETIME,
`created_at` DATETIME NOT NULL
`enabled` BOOLEAN NOT NULL DEFAULT FALSE,
`allow_subscribers` BOOLEAN NOT NULL DEFAULT TRUE,
`allow_vips` BOOLEAN NOT NULL DEFAULT TRUE,
`allow_moderators` BOOLEAN NOT NULL DEFAULT TRUE,
`timeout_duration` INTEGER NOT NULL DEFAULT 60,
`warning_message` VARCHAR(500) NOT NULL DEFAULT 'Les liens ne sont pas autorises dans le chat.'
);
CREATE TABLE IF NOT EXISTS `discord_invite` (
`code` VARCHAR(32) PRIMARY KEY,
`guild_id` VARCHAR(64) NOT NULL,
`channel_id` VARCHAR(64) NOT NULL,
`channel_name` VARCHAR(256),
`inviter_id` VARCHAR(64),
`inviter_name` VARCHAR(256),
`uses` INTEGER NOT NULL DEFAULT 0,
`max_uses` INTEGER NOT NULL DEFAULT 0,
`max_age` INTEGER NOT NULL DEFAULT 0,
`temporary` BOOLEAN NOT NULL DEFAULT FALSE,
`created_at` DATETIME,
`expires_at` DATETIME,
`revoked` BOOLEAN NOT NULL DEFAULT FALSE,
`last_sync` DATETIME NOT NULL
CREATE TABLE IF NOT EXISTS `twitch_allowed_domain` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`domain` VARCHAR(256) UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS `youtube_alert` (
CREATE TABLE IF NOT EXISTS `twitch_permit` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`username` VARCHAR(256) NOT NULL,
`expires_at` DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS `twitch_allowed_user` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`username` VARCHAR(256) UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS `twitch_banned_word` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`word` VARCHAR(256) UNIQUE NOT NULL,
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
`timeout_duration` INTEGER NOT NULL DEFAULT 60,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS `youtube_notification` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`enable` BOOLEAN NOT NULL DEFAULT TRUE,
`channel_id` VARCHAR(64) UNIQUE NOT NULL,
`channel_name` VARCHAR(256),
`channel_id` VARCHAR(128) NOT NULL,
`notify_channel` INTEGER NOT NULL,
`message` VARCHAR(2000) NOT NULL,
`last_video_id` VARCHAR(64),
`last_video_title` VARCHAR(512),
`last_video_date` DATETIME
`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
);
CREATE TABLE IF NOT EXISTS `webapp_user` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(64) UNIQUE NOT NULL,
email VARCHAR(256) UNIQUE NOT NULL,
password_hash VARCHAR(256) NOT NULL,
role VARCHAR(64) NOT NULL DEFAULT 'viewer_twitch',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS `webapp_role` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(64) UNIQUE NOT NULL,
level INTEGER NOT NULL DEFAULT 0,
description VARCHAR(256) NULL,
color VARCHAR(7) NULL DEFAULT '#6B7280',
icon VARCHAR(32) NULL
);
CREATE TABLE IF NOT EXISTS `webapp_page_permission` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_key VARCHAR(64) UNIQUE NOT NULL,
min_level INTEGER NOT NULL DEFAULT 0,
write_level INTEGER NULL,
category VARCHAR(32) NULL DEFAULT 'general',
description VARCHAR(256) NULL
);
CREATE TABLE IF NOT EXISTS `freeloot_entry` (
entry_id VARCHAR(256) PRIMARY KEY
);
-- Notifications d'événements Twitch (sub, follow, raid, clip) : chat Twitch et/ou canal Discord
CREATE TABLE IF NOT EXISTS `twitch_event_notification` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type VARCHAR(32) UNIQUE NOT NULL,
`enable` BOOLEAN NOT NULL DEFAULT TRUE,
notify_twitch_chat BOOLEAN NOT NULL DEFAULT TRUE,
notify_discord BOOLEAN NOT NULL DEFAULT FALSE,
discord_channel_id INTEGER NULL,
message_twitch VARCHAR(500) NOT NULL DEFAULT '',
message_discord VARCHAR(2000) NULL,
embed_color VARCHAR(8) DEFAULT '9146FF',
embed_title VARCHAR(256) NULL,
embed_description VARCHAR(2000) NULL,
embed_thumbnail BOOLEAN NOT NULL DEFAULT TRUE,
last_clip_id VARCHAR(128) NULL
);
+77 -235
View File
@@ -3,13 +3,13 @@ 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, DiscordInvite
from datetime import datetime, timezone, timedelta
from discord import Message, TextChannel, Member
from database.models import Configuration, Humeur, Commande
from discord import Message, TextChannel, Member, VoiceChannel, app_commands
from discordbot.humblebundle import checkHumbleBundleAndNotify
from discordbot.freegames import checkFreeGamesAndNotify
from discordbot.freeloot import checkFreeLootAndNotify
from discordbot.moderation import (
handle_warning_command,
handle_remove_warning_command,
@@ -21,70 +21,62 @@ from discordbot.moderation import (
handle_ban_list_command,
handle_staff_help_command,
handle_timeout_command,
handle_say_command
handle_say_command,
handle_transfer_command,
transfer_message_context_menu
)
from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
from discordbot.autorole import assignAutoRole
from discordbot.youtube import checkYouTubeVideos
from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms, on_message_auto_rooms
from protondb import searhProtonDb
from shared_stats import stats_manager, discord_bridge
class DiscordBot(discord.Client):
def __init__(self, *, intents: discord.Intents):
super().__init__(intents=intents)
self.tree = app_commands.CommandTree(self)
self.synced = False
async def setup_hook(self):
self.tree.add_command(transfer_message_context_menu)
logging.info("Commande contextuelle 'Déplacer le message' ajoutée au CommandTree")
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)
if not self.synced:
try:
logging.info("Synchronisation des commandes d'application en cours...")
for guild in self.guilds:
try:
synced = await self.tree.sync(guild=guild)
logging.info(f"{len(synced)} commande(s) synchronisée(s) pour le serveur '{guild.name}' (ID: {guild.id})")
except Exception as e:
logging.error(f"❌ Erreur lors de la synchronisation pour {guild.name}: {e}")
synced_global = await self.tree.sync()
logging.info(f"{len(synced_global)} commande(s) synchronisée(s) globalement")
self.synced = True
logging.info("🎉 Synchronisation complète terminée - Les commandes sont maintenant disponibles !")
except Exception as e:
logging.error(f"❌ Erreur lors de la synchronisation des commandes: {e}")
for c in self.get_all_channels() :
logging.info(f'{c.id} {c.name}')
for guild in self.guilds:
await updateInviteCache(guild)
# Enregistrer le bot dans le bridge pour communication avec Flask
discord_bridge.register_bot(self, self.loop)
# Mise à jour des stats partagées
self._update_shared_stats()
# Synchronisation initiale des invitations
await self.syncInvites()
self.loop.create_task(self.updateStatus())
self.loop.create_task(self.updateHumbleBundle())
self.loop.create_task(self.updateFreeGames())
self.loop.create_task(self.updateYoutubeAlerts())
self.loop.create_task(self._periodic_stats_update())
def _update_shared_stats(self):
"""Met à jour les statistiques partagées"""
total_members = sum(g.member_count or 0 for g in self.guilds)
total_channels = len(list(self.get_all_channels()))
stats_manager.update_discord_stats(
connected=True,
guilds=len(self.guilds),
members=total_members,
channels=total_channels,
bot_name=str(self.user),
bot_id=self.user.id
)
# Mise à jour des cogs/fonctionnalités activées
helper = ConfigurationHelper()
cogs = {
'Modération': helper.getValue('moderation_enable') or False,
'Ban': helper.getValue('moderation_ban_enable') or False,
'Kick': helper.getValue('moderation_kick_enable') or False,
'ProtonDB': helper.getValue('proton_db_enable_enable') or False,
'Humeurs': True, # Toujours actif si le bot tourne
'Jeux Gratuits': helper.getValue('freegames_enable') or False,
'Messages de bienvenue': helper.getValue('welcome_enable') or False,
'Auto-Role': helper.getValue('autorole_enable') or False,
}
stats_manager.update_cogs(cogs)
async def _periodic_stats_update(self):
"""Met à jour les stats périodiquement"""
while not self.is_closed():
await asyncio.sleep(60) # Toutes les minutes
self._update_shared_stats()
self.loop.create_task(self.updateYouTube())
self.loop.create_task(self.updateFreeLoot())
async def on_disconnect(self):
webapp.config["BOT_STATUS"]["discord_connected"] = False
async def updateStatus(self):
while not self.is_closed():
@@ -92,18 +84,8 @@ class DiscordBot(discord.Client):
if len(humeurs)>0 :
humeur = random.choice(humeurs)
if humeur != None:
# Récupérer les stats pour les variables
total_members = sum(g.member_count or 0 for g in self.guilds)
total_channels = len(list(self.get_all_channels()))
# Remplacer les variables dans le texte
status_text = humeur.text
status_text = status_text.replace('{servers}', str(len(self.guilds)))
status_text = status_text.replace('{members}', str(total_members))
status_text = status_text.replace('{channels}', str(total_channels))
logging.info(f'Changement de statut : {status_text}')
await self.change_presence(status = discord.Status.online, activity = discord.CustomActivity(status_text))
logging.info(f'Changement de statut : {humeur.text}')
await self.change_presence(status = discord.Status.online, activity = discord.CustomActivity(humeur.text))
await asyncio.sleep(10*60)
async def updateHumbleBundle(self):
@@ -111,16 +93,15 @@ class DiscordBot(discord.Client):
await checkHumbleBundleAndNotify(self)
await asyncio.sleep(30*60)
async def updateFreeGames(self):
async def updateYouTube(self):
while not self.is_closed():
await checkFreeGamesAndNotify(self)
await asyncio.sleep(60*60) # Vérification toutes les heures
await checkYouTubeVideos()
await asyncio.sleep(5*60)
async def updateYoutubeAlerts(self):
from youtubebot.youtube_alert import checkNewVideos
async def updateFreeLoot(self):
while not self.is_closed():
await checkNewVideos(self)
await asyncio.sleep(10*60) # Vérification toutes les 10 minutes
await checkFreeLootAndNotify(self)
await asyncio.sleep(30*60)
def getAllTextChannel(self) -> list[TextChannel]:
channels = []
@@ -128,7 +109,14 @@ class DiscordBot(discord.Client):
if isinstance(channel, TextChannel):
channels.append(channel)
return channels
def getAllVoiceChannels(self) -> list[VoiceChannel]:
channels = []
for channel in self.get_all_channels():
if isinstance(channel, VoiceChannel):
channels.append(channel)
return channels
def getAllRoles(self):
guilds_roles = []
for guild in self.guilds:
@@ -143,156 +131,7 @@ class DiscordBot(discord.Client):
'roles': roles
})
return guilds_roles
def getAllGuilds(self):
"""Retourne la liste de tous les serveurs Discord"""
guilds_list = []
for guild in self.guilds:
guilds_list.append({
'id': guild.id,
'name': guild.name,
'member_count': guild.member_count,
'icon_url': str(guild.icon.url) if guild.icon else None,
'owner_id': guild.owner_id
})
return guilds_list
async def leaveGuild(self, guild_id: int) -> bool:
"""Quitte un serveur Discord par son ID"""
guild = self.get_guild(guild_id)
if guild:
await guild.leave()
logging.info(f'Le bot a quitté le serveur : {guild.name} (ID: {guild_id})')
return True
return False
async def syncInvites(self, guild_id: int = None) -> dict:
"""Synchronise les invitations Discord avec la base de données"""
result = {'synced': 0, 'errors': [], 'guilds': []}
guilds_to_sync = [self.get_guild(guild_id)] if guild_id else self.guilds
for guild in guilds_to_sync:
if not guild:
continue
try:
invites = await guild.invites()
now = datetime.now(timezone.utc)
# Marquer les invitations existantes comme révoquées (on les réactivera si elles existent encore)
existing_invites = DiscordInvite.query.filter_by(guild_id=str(guild.id), revoked=False).all()
existing_codes = {inv.code for inv in existing_invites}
current_codes = {inv.code for inv in invites}
# Marquer comme révoquées celles qui n'existent plus
for inv in existing_invites:
if inv.code not in current_codes:
inv.revoked = True
inv.last_sync = now
for invite in invites:
# Calculer la date d'expiration
expires_at = None
if invite.max_age and invite.max_age > 0 and invite.created_at:
expires_at = invite.created_at + timedelta(seconds=invite.max_age)
# Chercher si l'invitation existe déjà
db_invite = DiscordInvite.query.filter_by(code=invite.code).first()
if db_invite:
# Mettre à jour l'invitation existante
db_invite.uses = invite.uses or 0
db_invite.revoked = False
db_invite.last_sync = now
db_invite.channel_name = invite.channel.name if invite.channel else None
db_invite.inviter_name = invite.inviter.name if invite.inviter else None
else:
# Créer une nouvelle invitation
db_invite = DiscordInvite(
code=invite.code,
guild_id=str(guild.id),
channel_id=str(invite.channel.id) if invite.channel else '',
channel_name=invite.channel.name if invite.channel else None,
inviter_id=str(invite.inviter.id) if invite.inviter else None,
inviter_name=invite.inviter.name if invite.inviter else None,
uses=invite.uses or 0,
max_uses=invite.max_uses or 0,
max_age=invite.max_age or 0,
temporary=invite.temporary or False,
created_at=invite.created_at,
expires_at=expires_at,
revoked=False,
last_sync=now
)
db.session.add(db_invite)
result['synced'] += 1
db.session.commit()
result['guilds'].append({'id': guild.id, 'name': guild.name, 'invites': len(invites)})
logging.info(f'Invitations synchronisées pour {guild.name}: {len(invites)} invitations')
except Exception as e:
logging.error(f'Erreur lors de la synchronisation des invitations pour {guild.name}: {e}')
result['errors'].append(f'{guild.name}: {str(e)}')
return result
async def revokeInvite(self, invite_code: str) -> dict:
"""Révoque une invitation Discord"""
result = {'success': False, 'message': '', 'invite_code': invite_code}
try:
# Chercher l'invitation dans la BDD
db_invite = DiscordInvite.query.filter_by(code=invite_code).first()
if not db_invite:
result['message'] = 'Invitation non trouvée dans la base de données'
return result
# Récupérer le guild
guild = self.get_guild(int(db_invite.guild_id))
if not guild:
result['message'] = 'Serveur Discord non trouvé'
return result
# Chercher l'invitation sur Discord
try:
invites = await guild.invites()
discord_invite = next((inv for inv in invites if inv.code == invite_code), None)
if discord_invite:
await discord_invite.delete(reason='Révoquée via interface web')
logging.info(f'Invitation {invite_code} révoquée sur Discord')
except Exception as e:
logging.warning(f'Impossible de révoquer l\'invitation sur Discord: {e}')
# Marquer comme révoquée dans la BDD
db_invite.revoked = True
db_invite.last_sync = datetime.now(timezone.utc)
db.session.commit()
result['success'] = True
result['message'] = f'Invitation {invite_code} révoquée avec succès'
except Exception as e:
logging.error(f'Erreur lors de la révocation de l\'invitation {invite_code}: {e}')
result['message'] = f'Erreur: {str(e)}'
return result
def getInvites(self, guild_id: int = None, include_revoked: bool = False) -> list:
"""Récupère les invitations depuis la base de données"""
query = DiscordInvite.query
if guild_id:
query = query.filter_by(guild_id=str(guild_id))
if not include_revoked:
query = query.filter_by(revoked=False)
return query.order_by(DiscordInvite.created_at.desc()).all()
def begin(self) :
token = Configuration.query.filter_by(key='discord_token').first()
@@ -312,6 +151,10 @@ bot = DiscordBot(intents=intents)
async def on_message(message: Message):
if message.author == bot.user:
return
# Gestion des messages dans les auto rooms (avant le check des commandes !)
await on_message_auto_rooms(bot, message)
if not message.content.startswith('!'):
return
command_name = message.content.split()[0]
@@ -359,6 +202,10 @@ async def on_message(message: Message):
await handle_say_command(message, bot)
return
if command_name in ['!transfert', '!transfer', '!move']:
await handle_transfer_command(message, bot)
return
if command_name in ['!aide', '!help']:
await handle_staff_help_command(message, bot)
return
@@ -468,10 +315,17 @@ async def on_message(message: Message):
except Exception as e:
logging.error(f"Échec de l'envoi de l'embed ProtonDB : {e}")
@bot.event
async def on_voice_state_update(member: Member, before, after):
await on_voice_state_update_auto_rooms(bot, member, before, after)
@bot.event
async def on_raw_reaction_add(payload: discord.RawReactionActionEvent):
await on_raw_reaction_add_auto_rooms(bot, payload)
@bot.event
async def on_member_join(member: Member):
await sendWelcomeMessage(bot, member)
await assignAutoRole(bot, member)
@bot.event
async def on_member_remove(member: Member):
@@ -480,20 +334,8 @@ async def on_member_remove(member: Member):
@bot.event
async def on_invite_create(invite):
await updateInviteCache(invite.guild)
# Synchroniser la nouvelle invitation avec la BDD
await bot.syncInvites(invite.guild.id)
@bot.event
async def on_invite_delete(invite):
await updateInviteCache(invite.guild)
# Marquer l'invitation comme révoquée dans la BDD
try:
db_invite = DiscordInvite.query.filter_by(code=invite.code).first()
if db_invite:
db_invite.revoked = True
db_invite.last_sync = datetime.now(timezone.utc)
db.session.commit()
logging.info(f'Invitation {invite.code} marquée comme révoquée')
except Exception as e:
logging.error(f'Erreur lors de la révocation de l\'invitation {invite.code}: {e}')
+557
View File
@@ -0,0 +1,557 @@
# discordbot/auto_rooms.py — Auto rooms : message et réactions dans la partie texte du salon vocal (onglet Discussion)
import logging
from typing import Optional
import discord
from discord import Member, VoiceState
from database.helpers import ConfigurationHelper
# (guild_id, owner_id) -> room_data (voice_channel_id, control_message_id, whitelist, blacklist, access_mode)
_rooms: dict[tuple[int, int], dict] = {}
# message_id -> (guild_id, owner_id) pour retrouver la room depuis une réaction
_control_message_ids: dict[int, tuple[int, int]] = {}
# Emoji -> action
REACTIONS = [
("🔓", "open", "Ouvert"),
("🔒", "closed", "Fermé"),
("🔐", "private", "Privé"),
("", "whitelist", "Liste blanche"),
("🚫", "blacklist", "Liste noire"),
("🧹", "purge", "Purge"),
("👑", "transfer", "Propriété"),
("🎤", "speak", "Micro"),
("📹", "stream", "Vidéo"),
("📊", "soundboards", "Soundboards"),
("📝", "status", "Statut"),
]
def _status_display(access_mode: str) -> str:
"""Cadenas ouvert ou fermé selon si le salon est ouvert ou pas."""
if access_mode == "open":
return "🔓 Ouvert"
if access_mode == "closed":
return "🔒 Fermé"
if access_mode == "private":
return "🔐 Privé"
return "🔓 Ouvert"
def _status_emoji(access_mode: str) -> str:
"""Emoji cadenas seul pour le nom du channel."""
if access_mode == "private":
return "🔐"
return "🔓" if access_mode == "open" else "🔒"
def _build_control_embed(owner: Member, voice_channel: discord.VoiceChannel, access_mode: str, room: dict = None) -> discord.Embed:
"""Construit l'embed de config avec infos du salon."""
embed = discord.Embed(
title="⚙️ Configuration du salon",
description=(
"Voici l'espace de configuration de votre salon vocal temporaire. "
"Les différentes options disponibles vous permettent de personnaliser les permissions de votre salon selon vos préférences."
),
color=discord.Color.orange()
)
# Récupération des infos
whitelist = room.get("whitelist", set()) if room else set()
blacklist = room.get("blacklist", set()) if room else set()
whitelist_text = f"{len(whitelist)} membre(s)" if whitelist else "Aucun"
blacklist_text = f"{len(blacklist)} membre(s)" if blacklist else "Aucun"
# Section Propriétaire
embed.add_field(
name=f"👤 Propriétaire du salon : {owner.display_name}",
value="",
inline=False
)
# Section Modes d'accès
mode_open = "🔓 **Ouvert**\nLe salon sera ouvert à tous les membres, sauf ceux figurant sur la liste noire."
mode_closed = "🔒 **Fermé**\nLe salon sera visible de tous, mais seulement accessible à la liste blanche."
mode_private = "🔐 **Privé**\nLe salon ne sera visible et accessible qu'aux membres de la liste blanche."
embed.add_field(name=mode_open, value="", inline=True)
embed.add_field(name=mode_closed, value="", inline=True)
embed.add_field(name=mode_private, value="", inline=True)
# Section Listes
embed.add_field(
name="📝 **Liste blanche**",
value=f"Les membres présents dans cette liste pourront toujours rejoindre le salon.\n\n{whitelist_text}",
inline=True
)
embed.add_field(
name="🚫 **Liste noire**",
value=f"Les membres présents dans cette liste ne pourront jamais rejoindre le salon.\n\n{blacklist_text}",
inline=True
)
embed.add_field(name="\u200b", value="", inline=True) # Spacer
# Section Purge
embed.add_field(
name="🧹 **Purge**",
value="Déconnecter tous les membres du salon vocal à l'exception de ceux présents dans la liste blanche.",
inline=False
)
# Section Transfert
embed.add_field(
name="👑 **Transférer**",
value="Transférer la gestion du salon au membre de votre choix.",
inline=False
)
# Note importante
embed.add_field(
name="💡",
value="Les membres de la liste blanche ne sont pas impactés par les permissions refusées aux membres.",
inline=False
)
embed.set_footer(text="Réagissez avec les émojis ci-dessous pour configurer votre salon")
return embed
def _room_key(guild_id: int, owner_id: int) -> tuple[int, int]:
return (guild_id, owner_id)
def _get_room(guild_id: int, owner_id: int) -> Optional[dict]:
return _rooms.get(_room_key(guild_id, owner_id))
def _set_room(guild_id: int, owner_id: int, data: dict):
_rooms[_room_key(guild_id, owner_id)] = data
mid = data.get("control_message_id")
if mid:
_control_message_ids[mid] = (guild_id, owner_id)
def _del_room(guild_id: int, owner_id: int):
data = _rooms.pop(_room_key(guild_id, owner_id), None)
if data and data.get("control_message_id"):
_control_message_ids.pop(data["control_message_id"], None)
def _find_room_by_channel(guild_id: int, channel_id: int) -> Optional[tuple[int, dict]]:
for (gid, oid), data in _rooms.items():
if gid == guild_id and data.get("voice_channel_id") == channel_id:
return (oid, data)
return None
def _find_room_by_message(message_id: int) -> Optional[tuple[int, int, dict]]:
key = _control_message_ids.get(message_id)
if not key:
return None
guild_id, owner_id = key
data = _get_room(guild_id, owner_id)
if not data:
_control_message_ids.pop(message_id, None)
return None
return (guild_id, owner_id, data)
async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist: set, blacklist: set):
guild = channel.guild
everyone = guild.default_role
overwrites = dict(channel.overwrites) # Récupérer les overwrites existants
# Préserver les permissions existantes pour everyone (stream, speak, soundboards, etc.)
existing_everyone_ow = overwrites.get(everyone, discord.PermissionOverwrite())
everyone_ow = discord.PermissionOverwrite()
# Copier les permissions importantes qui ne doivent pas être écrasées
everyone_ow.stream = existing_everyone_ow.stream
everyone_ow.speak = existing_everyone_ow.speak
everyone_ow.use_soundboard = existing_everyone_ow.use_soundboard
if mode == "open":
everyone_ow.connect = True
everyone_ow.view_channel = True
# Retirer les overwrites des membres qui ne sont plus dans la blacklist
for target in list(overwrites.keys()):
if target != everyone and isinstance(target, discord.Member):
if target.id not in blacklist:
overwrites.pop(target, None)
# Ajouter les overwrites pour la blacklist
for uid in blacklist:
m = guild.get_member(uid)
if m:
overwrites[m] = discord.PermissionOverwrite(connect=False, view_channel=True)
elif mode == "closed":
everyone_ow.connect = False
everyone_ow.view_channel = True
# Retirer les overwrites des membres qui ne sont plus dans la whitelist
for target in list(overwrites.keys()):
if target != everyone and isinstance(target, discord.Member):
if target.id not in whitelist:
overwrites.pop(target, None)
# Ajouter les overwrites pour la whitelist
for uid in whitelist:
m = guild.get_member(uid)
if m:
overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True)
elif mode == "private":
everyone_ow.connect = False
everyone_ow.view_channel = False
# Retirer les overwrites des membres qui ne sont plus dans la whitelist
for target in list(overwrites.keys()):
if target != everyone and isinstance(target, discord.Member):
if target.id not in whitelist:
overwrites.pop(target, None)
# Ajouter les overwrites pour la whitelist
for uid in whitelist:
m = guild.get_member(uid)
if m:
overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True)
overwrites[everyone] = everyone_ow
await channel.edit(overwrites=overwrites)
async def _handle_reaction_action(bot: discord.Client, guild_id: int, owner_id: int, action: str, channel):
"""channel = salon vocal (partie texte / onglet Discussion)."""
room = _get_room(guild_id, owner_id)
if not room:
await channel.send("Ce salon n'existe plus.")
return
voice_channel = bot.get_channel(room["voice_channel_id"])
if not voice_channel or not isinstance(voice_channel, discord.VoiceChannel):
await channel.send("Salon vocal introuvable.")
return
if action in ("open", "closed", "private"):
room["access_mode"] = action
await _apply_access_mode(voice_channel, action, room.get("whitelist", set()), room.get("blacklist", set()))
await channel.send(f"✅ Accès du salon défini sur **{_status_display(action)}**.")
# Mettre à jour l'embed
await _update_control_panel(bot, guild_id, owner_id, channel)
elif action == "whitelist":
whitelist = room.get("whitelist", set())
whitelist_text = ", ".join([f"<@{uid}>" for uid in whitelist]) if whitelist else "Aucun membre"
await channel.send(
f"**📝 Liste blanche actuelle :** {whitelist_text}\n\n"
f"Mentionnez un membre pour l'ajouter ou le retirer de la liste blanche."
)
room["awaiting_whitelist"] = True
elif action == "blacklist":
blacklist = room.get("blacklist", set())
blacklist_text = ", ".join([f"<@{uid}>" for uid in blacklist]) if blacklist else "Aucun membre"
await channel.send(
f"**🚫 Liste noire actuelle :** {blacklist_text}\n\n"
f"Mentionnez un membre pour l'ajouter ou le retirer de la liste noire."
)
room["awaiting_blacklist"] = True
elif action == "purge":
whitelist = room.get("whitelist", set())
kicked = 0
for member in list(voice_channel.members):
if member.id == owner_id or member.id in whitelist:
continue
try:
await member.move_to(None)
kicked += 1
except discord.HTTPException:
pass
await channel.send(f"🧹 Purge effectuée : {kicked} membre(s) déconnecté(s).")
elif action == "transfer":
await channel.send(
f"**👑 Transfert de propriété**\n\n"
f"Mentionnez le membre à qui vous souhaitez transférer la gestion du salon."
)
room["awaiting_transfer"] = True
elif action in ("speak", "stream", "soundboards"):
everyone = voice_channel.guild.default_role
overwrites = dict(voice_channel.overwrites)
ow = overwrites.get(everyone) or discord.PermissionOverwrite()
# BUG FIX : Par défaut, Discord autorise stream, speak et soundboards
# Si la permission n'est pas explicitement définie (None), on considère qu'elle est True
current = getattr(ow, action)
if current is None:
# Permission non définie = autorisée par défaut dans Discord
# On veut la désactiver lors du premier clic
setattr(ow, action, False)
new_value = False
else:
# Permission définie, on l'inverse
setattr(ow, action, not current)
new_value = not current
overwrites[everyone] = ow
await voice_channel.edit(overwrites=overwrites)
labels = {"speak": "Micro", "stream": "Vidéo/Partage d'écran", "soundboards": "Soundboards"}
label = labels.get(action, action.capitalize())
await channel.send(f"{label} : {'autorisé' if new_value else 'désactivé'} pour tous.")
elif action == "status":
current_status = voice_channel.status or "Aucun statut défini"
await channel.send(
f"**Statut actuel du salon :** {current_status}\n\n"
f"Pour modifier le statut du salon (le texte affiché en haut du salon vocal), "
f"répondez avec le nouveau statut (max 500 caractères).\n"
f"💡 Pour supprimer le statut, répondez avec `clear` ou `effacer`."
)
room["awaiting_status"] = True
async def _update_control_panel(bot: discord.Client, guild_id: int, owner_id: int, channel):
"""Met à jour le panneau de contrôle avec les nouvelles informations."""
room = _get_room(guild_id, owner_id)
if not room:
return
control_message_id = room.get("control_message_id")
if not control_message_id:
return
voice_channel = bot.get_channel(room["voice_channel_id"])
if not voice_channel or not isinstance(voice_channel, discord.VoiceChannel):
return
owner = voice_channel.guild.get_member(owner_id)
if not owner:
return
try:
msg = await channel.fetch_message(control_message_id)
embed = _build_control_embed(owner, voice_channel, room.get("access_mode", "open"), room)
await msg.edit(embed=embed)
except discord.HTTPException:
pass
async def send_control_panel(bot: discord.Client, guild_id: int, owner: Member, voice_channel: discord.VoiceChannel, room: dict) -> Optional[int]:
"""Envoie le message de config avec réactions dans la partie texte du salon vocal (onglet Discussion). Seul le proprio peut réagir. Retourne l'id du message."""
embed = _build_control_embed(owner, voice_channel, "open", room)
try:
# Message dans la partie texte du vocal (onglet Discussion à droite)
msg = await voice_channel.send(embed=embed)
for emoji, _action, _label in REACTIONS:
await msg.add_reaction(emoji)
return msg.id
except discord.HTTPException as e:
logging.error(f"Impossible d'envoyer le panneau Auto Room dans le vocal : {e}")
return None
async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member, before: VoiceState, after: VoiceState):
config = ConfigurationHelper()
if not config.getValue("auto_rooms_enable"):
return
trigger_channel_id = config.getIntValue("auto_rooms_channel_id")
if not trigger_channel_id:
return
guild = member.guild
if after.channel and after.channel.id == trigger_channel_id:
category = after.channel.category
# Nom du salon (simple, sans emoji pour éviter le rate limiting)
channel_name = f"Salon de {member.display_name}"
try:
new_channel = await guild.create_voice_channel(
name=channel_name,
category=category,
reason="Auto room"
)
await member.move_to(new_channel)
# Créer la room data d'abord
room_data = {
"guild_id": guild.id,
"voice_channel_id": new_channel.id,
"control_message_id": None, # Sera mis à jour après
"owner_id": member.id,
"whitelist": set(),
"blacklist": set(),
"access_mode": "open",
}
control_message_id = await send_control_panel(bot, guild.id, member, new_channel, room_data)
room_data["control_message_id"] = control_message_id
_set_room(guild.id, member.id, room_data)
logging.info(f"Auto room créé : {new_channel.name} pour {member.display_name}")
except discord.HTTPException as e:
logging.error(f"Erreur création auto room : {e}")
if before.channel and before.channel.id != trigger_channel_id:
result = _find_room_by_channel(guild.id, before.channel.id)
if result:
owner_id, room = result
remaining = [m for m in before.channel.members if m.id != member.id]
if member.id == owner_id:
_del_room(guild.id, owner_id)
try:
await before.channel.delete(reason="Propriétaire parti (auto room)")
except discord.HTTPException:
pass
elif len(remaining) == 0:
_del_room(guild.id, owner_id)
try:
await before.channel.delete(reason="Auto room vide")
except discord.HTTPException:
pass
async def on_message_auto_rooms(bot: discord.Client, message: discord.Message):
"""Gère les messages dans les salons vocaux pour les actions (statut, liste blanche/noire, etc.)."""
if message.author.bot:
return
if not ConfigurationHelper().getValue("auto_rooms_enable"):
return
# Vérifier si c'est dans un salon vocal (partie texte)
if not isinstance(message.channel, discord.VoiceChannel):
return
# Trouver si c'est une auto room
result = _find_room_by_channel(message.guild.id, message.channel.id)
if not result:
return
owner_id, room = result
# Seul le propriétaire peut interagir
if message.author.id != owner_id:
return
voice_channel = message.channel
# Gestion du statut de salon
if room.get("awaiting_status"):
room["awaiting_status"] = False
new_status = message.content.strip()
try:
if new_status.lower() in ("clear", "effacer", "supprimer", "delete"):
await voice_channel.edit(status=None)
await message.channel.send("✅ Le statut du salon a été supprimé.")
elif len(new_status) > 500:
await message.channel.send("❌ Le statut ne peut pas dépasser 500 caractères.")
room["awaiting_status"] = True # Réessayer
else:
await voice_channel.edit(status=new_status)
await message.channel.send(f"✅ Le statut du salon a été mis à jour : **{new_status}**")
except discord.HTTPException as e:
await message.channel.send(f"❌ Erreur lors de la modification du statut : {e}")
return
# Gestion de la liste blanche (si en attente)
if room.get("awaiting_whitelist"):
room["awaiting_whitelist"] = False
if message.mentions:
target = message.mentions[0]
whitelist = room.get("whitelist", set())
if target.id in whitelist:
whitelist.remove(target.id)
await message.channel.send(f"{target.mention} a été retiré de la liste blanche.")
else:
whitelist.add(target.id)
await message.channel.send(f"{target.mention} a été ajouté à la liste blanche.")
room["whitelist"] = whitelist
await _apply_access_mode(voice_channel, room.get("access_mode", "open"), whitelist, room.get("blacklist", set()))
await _update_control_panel(bot, message.guild.id, owner_id, message.channel)
return
# Gestion de la liste noire (si en attente)
if room.get("awaiting_blacklist"):
room["awaiting_blacklist"] = False
if message.mentions:
target = message.mentions[0]
blacklist = room.get("blacklist", set())
if target.id in blacklist:
blacklist.remove(target.id)
await message.channel.send(f"{target.mention} a été retiré de la liste noire.")
else:
blacklist.add(target.id)
await message.channel.send(f"{target.mention} a été ajouté à la liste noire.")
room["blacklist"] = blacklist
await _apply_access_mode(voice_channel, room.get("access_mode", "open"), room.get("whitelist", set()), blacklist)
await _update_control_panel(bot, message.guild.id, owner_id, message.channel)
return
# Gestion du transfert de propriété (si en attente)
if room.get("awaiting_transfer"):
room["awaiting_transfer"] = False
if message.mentions:
new_owner = message.mentions[0]
if new_owner.id == owner_id:
await message.channel.send("❌ Vous êtes déjà le propriétaire du salon.")
return
# Transférer la propriété
old_owner_id = owner_id
_del_room(message.guild.id, old_owner_id)
room["owner_id"] = new_owner.id
_set_room(message.guild.id, new_owner.id, room)
# Renommer le salon (optionnel, peut être commenté pour éviter rate limit)
try:
new_name = f"Salon de {new_owner.display_name}"
await voice_channel.edit(name=new_name)
except discord.HTTPException as e:
logging.warning(f"Impossible de renommer le salon lors du transfert : {e}")
await message.channel.send(f"✅ La propriété du salon a été transférée à {new_owner.mention}.")
await _update_control_panel(bot, message.guild.id, new_owner.id, message.channel)
return
async def on_raw_reaction_add_auto_rooms(bot: discord.Client, payload: discord.RawReactionActionEvent):
"""Seul le propriétaire peut réagir ; on retire la réaction des autres."""
if payload.user_id == bot.user.id:
return
if not ConfigurationHelper().getValue("auto_rooms_enable"):
return
room_info = _find_room_by_message(payload.message_id)
if not room_info:
return
guild_id, owner_id, room = room_info
if payload.user_id != owner_id:
try:
channel = bot.get_channel(payload.channel_id)
if channel:
msg = await channel.fetch_message(payload.message_id)
user = payload.member or await bot.fetch_user(payload.user_id)
await msg.remove_reaction(payload.emoji, user)
except discord.HTTPException:
pass
return
emoji_str = str(payload.emoji)
action = None
for e, a, _ in REACTIONS:
if e == emoji_str:
action = a
break
if not action:
return
# Canal = salon vocal (le message est dans la partie texte du vocal)
channel = bot.get_channel(payload.channel_id)
if not channel or not hasattr(channel, "send"):
return
await _handle_reaction_action(bot, guild_id, owner_id, action, channel)
try:
msg = await channel.fetch_message(payload.message_id)
user = payload.member or await bot.fetch_user(payload.user_id)
await msg.remove_reaction(payload.emoji, user)
except discord.HTTPException:
pass
-33
View File
@@ -1,33 +0,0 @@
import discord
import logging
from database.helpers import ConfigurationHelper
from discord import Member
async def assignAutoRole(bot: discord.Client, member: Member):
"""Attribue automatiquement un rôle aux nouveaux membres"""
config = ConfigurationHelper()
if not config.getValue('autorole_enable'):
return
role_id = config.getIntValue('autorole_role_id')
if not role_id:
logging.warning('Auto-role activé mais aucun rôle configuré')
return
# Chercher le rôle dans le serveur du membre
role = member.guild.get_role(role_id)
if not role:
logging.error(f'Rôle auto-role {role_id} introuvable dans le serveur {member.guild.name}')
return
try:
await member.add_roles(role, reason='Auto-role: Attribution automatique à l\'arrivée')
logging.info(f'Auto-role: Rôle "{role.name}" attribué à {member.name} sur {member.guild.name}')
except discord.Forbidden:
logging.error(f'Auto-role: Permission refusée pour attribuer le rôle "{role.name}" à {member.name}')
except discord.HTTPException as e:
logging.error(f'Auto-role: Erreur HTTP lors de l\'attribution du rôle : {e}')
except Exception as e:
logging.error(f'Auto-role: Erreur inattendue : {e}')
-270
View File
@@ -1,270 +0,0 @@
import discord
import feedparser
import logging
import re
from datetime import datetime, timezone
from html import unescape
from database import db
from database.helpers import ConfigurationHelper
from database.models import FreeGame
from discord import Client
RSS_URL = "https://feed.eikowagenknecht.com/lootscraper.xml"
KNOWN_SOURCES = {
'epic': 'Epic Games',
'steam': 'Steam',
'gog': 'GOG',
'amazon': 'Amazon Prime',
'humble': 'Humble Bundle',
'apple': 'Apple App Store',
'google': 'Google Play',
'itch': 'Itch.io',
'ubisoft': 'Ubisoft',
'indiegala': 'IndieGala'
}
def _isEnabled():
helper = ConfigurationHelper()
return helper.getValue('freegames_enable') and helper.getIntValue('freegames_channel') != 0
def _parseSource(title: str) -> str:
title_lower = title.lower()
for key, name in KNOWN_SOURCES.items():
if key in title_lower:
return name
match = re.search(r'\(([^)]+)\)', title)
if match:
return match.group(1)
return "Autre"
def _parseEntryContent(entry) -> dict:
content = entry.get('content', [{}])[0].get('value', '') if entry.get('content') else ''
summary = entry.get('summary', '')
image_url = None
img_match = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', content or summary)
if img_match:
image_url = img_match.group(1)
valid_from = None
valid_to = None
from_match = re.search(r'Offer valid from:</b>\s*([^<]+)', content)
if from_match:
try:
date_str = from_match.group(1).strip()
valid_from = datetime.strptime(date_str, '%Y-%m-%d %H:%M')
except:
pass
to_match = re.search(r'Offer valid to:</b>\s*([^<]+)', content)
if to_match:
try:
date_str = to_match.group(1).strip()
valid_to = datetime.strptime(date_str, '%Y-%m-%d %H:%M')
except:
pass
description = None
desc_match = re.search(r'Description:</b>\s*([^<]+)', content)
if desc_match:
description = unescape(desc_match.group(1).strip())
price = None
price_match = re.search(r'Recommended price[^:]*:</b>\s*([^<]+)', content)
if price_match:
price = price_match.group(1).strip()
genres = []
for category in entry.get('tags', []):
term = category.get('term', '')
if term.startswith('Genre:'):
genres.append(term.replace('Genre:', '').strip())
return {
'image_url': image_url,
'valid_from': valid_from,
'valid_to': valid_to,
'description': description,
'price': price,
'genres': genres
}
def _fetchRSS() -> list:
try:
feed = feedparser.parse(RSS_URL)
if feed.bozo:
logging.warning(f"Erreur de parsing RSS: {feed.bozo_exception}")
return feed.entries
except Exception as e:
logging.error(f"Échec de la récupération du flux RSS: {e}")
return []
def _isSourceEnabled(source: str) -> bool:
helper = ConfigurationHelper()
enabled_sources = helper.getValue('freegames_sources')
if not enabled_sources:
return True
enabled_list = [s.strip().lower() for s in enabled_sources.split(',')]
return source.lower() in enabled_list or any(k in source.lower() for k in enabled_list)
def _getMentionText() -> str:
helper = ConfigurationHelper()
mention_type = helper.getValue('freegames_mention_type')
if mention_type == 'everyone':
return '@everyone '
elif mention_type == 'here':
return '@here '
elif mention_type == 'role':
role_id = helper.getIntValue('freegames_mention_role')
if role_id:
return f'<@&{role_id}> '
return ''
def _formatEmbed(game: FreeGame, parsed_data: dict) -> discord.Embed:
clean_title = game.title
if ' - ' in clean_title:
clean_title = clean_title.split(' - ', 1)[1]
embed = discord.Embed(
title=f"{clean_title} gratuit sur {game.source} !",
url=game.url,
color=discord.Color.green()
)
if game.description:
desc = game.description[:300] + '...' if len(game.description) > 300 else game.description
embed.description = desc
price_line = ""
if parsed_data.get('price'):
price_line = f"~~{parsed_data['price']}~~ **Gratuit**"
else:
price_line = "**Gratuit**"
if game.valid_to:
price_line += f" jusqu'au {game.valid_to.strftime('%d/%m/%Y')}"
embed.add_field(name="💰 Prix", value=price_line, inline=False)
embed.add_field(name="🔗 Récupérer le jeu", value=f"[Ouvrir dans la boutique !]({game.url})", inline=False)
if game.image_url:
embed.set_image(url=game.image_url)
embed.set_footer(text="🎁 Jeu à looter - Mamie Henriette")
return embed
def fetchAndStoreGames() -> list[FreeGame]:
entries = _fetchRSS()
new_games = []
for entry in entries:
entry_id = entry.get('id', entry.get('link', ''))
existing = FreeGame.query.filter_by(entry_id=entry_id).first()
if existing:
continue
title = entry.get('title', 'Jeu inconnu')
source = _parseSource(title)
url = entry.get('link', '')
parsed = _parseEntryContent(entry)
game = FreeGame(
entry_id=entry_id,
title=title,
source=source,
url=url,
image_url=parsed.get('image_url'),
description=parsed.get('description'),
valid_from=parsed.get('valid_from'),
valid_to=parsed.get('valid_to'),
notified=False,
created_at=datetime.now(timezone.utc)
)
db.session.add(game)
new_games.append(game)
if new_games:
db.session.commit()
logging.info(f"{len(new_games)} nouveaux jeux gratuits ajoutés")
return new_games
def getPendingGames() -> list[FreeGame]:
return FreeGame.query.filter_by(notified=False).order_by(FreeGame.created_at.desc()).all()
def getAllGames() -> list[FreeGame]:
return FreeGame.query.order_by(FreeGame.created_at.desc()).all()
async def notifyGame(bot: Client, game_id: int) -> bool:
if not _isEnabled():
return False
game = FreeGame.query.get(game_id)
if not game or game.notified:
return False
helper = ConfigurationHelper()
channel_id = helper.getIntValue('freegames_channel')
channel = bot.get_channel(channel_id)
if not channel:
logging.error(f"Canal Free Games {channel_id} introuvable")
return False
try:
parsed = _parseEntryContent({'content': [{'value': game.description or ''}]})
embed = _formatEmbed(game, parsed)
mention = _getMentionText()
await channel.send(content=mention if mention else None, embed=embed)
game.notified = True
game.notified_at = datetime.now(timezone.utc)
db.session.commit()
logging.info(f"Notification envoyée pour: {game.title}")
return True
except Exception as e:
logging.error(f"Échec de l'envoi de la notification Free Games: {e}")
return False
async def checkFreeGamesAndNotify(bot: Client):
if not _isEnabled():
logging.debug('Free Games est désactivé')
return
helper = ConfigurationHelper()
auto_notify = helper.getValue('freegames_auto_notify')
new_games = fetchAndStoreGames()
if auto_notify:
for game in new_games:
if _isSourceEnabled(game.source):
await notifyGame(bot, game.id)
def markAsNotified(game_id: int) -> bool:
game = FreeGame.query.get(game_id)
if game:
game.notified = True
game.notified_at = datetime.now(timezone.utc)
db.session.commit()
return True
return False
def resetNotification(game_id: int) -> bool:
game = FreeGame.query.get(game_id)
if game:
game.notified = False
game.notified_at = None
db.session.commit()
return True
return False
+232
View File
@@ -0,0 +1,232 @@
# FreeLoot Discord : notifications depuis le feed LootScraper (jeux gratuits Epic, Amazon Prime, GOG, etc.)
import asyncio
import logging
from discord import Client
from database import db
from database.helpers import ConfigurationHelper
from database.models import FreeLootEntry
from freeloot_feed import (
SOURCES,
fetch_feed,
source_key_from_entry,
game_name_from_title,
extract_image_from_content,
extract_description_from_content,
extract_valid_to_from_content,
extract_recommended_price_from_content,
extract_genres_from_content,
extract_rating_from_content,
)
def _get_mention_content() -> str:
"""Construit le contenu du message (mentions) depuis la config."""
raw = ConfigurationHelper().getValue("freeloot_mention")
if not raw or not str(raw).strip():
return ""
parts = []
for s in str(raw).strip().split(","):
s = s.strip()
if s == "everyone":
parts.append("@everyone")
elif s == "here":
parts.append("@here")
elif s.isdigit():
parts.append(f"<@&{s}>")
return " ".join(parts) if parts else ""
def _is_enabled_source(source_key: str) -> bool:
"""Vérifie si cette source est activée dans la config (freeloot_sources)."""
raw = ConfigurationHelper().getValue("freeloot_sources")
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
return True
enabled = [s.strip() for s in str(raw).split(",") if s.strip()]
return source_key in enabled if enabled else True
def _store_label_for_title(source_key: str) -> str:
"""Libellé court pour le titre style DraftBot (ex: 'l'Epic Games Store')."""
labels = {
"epic_pc": "l'Epic Games Store",
"epic_android": "l'Epic Games Store (Android)",
"epic_ios": "l'Epic Games Store (iOS)",
"amazon_prime": "Amazon Prime Gaming",
"gog": "GOG",
"google_play": "Google Play",
"apple_app_store": "l'App Store",
}
return labels.get(source_key, "la boutique")
# Logo (thumbnail) de chaque boutique pour l'embed Discord (affiché en haut à droite)
SOURCE_LOGO_URLS = {
"epic_pc": "https://store.epicgames.com/favicon.ico",
"epic_android": "https://store.epicgames.com/favicon.ico",
"epic_ios": "https://store.epicgames.com/favicon.ico",
"amazon_prime": "https://gaming.amazon.com/favicon.ico",
"gog": "https://www.gog.com/favicon.ico",
"google_play": "https://play.google.com/favicon.ico",
"apple_app_store": "https://www.apple.com/favicon.ico",
}
def _build_embed(entry: dict, source_key: str):
import discord
game_name = game_name_from_title(entry["title"])
source_label = next((s[1] for s in SOURCES if s[0] == source_key), source_key)
link = entry.get("link") or ""
content_raw = entry.get("content") or ""
img_url = extract_image_from_content(content_raw)
description = extract_description_from_content(content_raw, max_len=350)
valid_to = extract_valid_to_from_content(content_raw)
store_title = _store_label_for_title(source_key)
# Couleur barre gauche style DraftBot (orange-rouge)
color = 0xE67E22
title = f"{game_name} gratuit sur {store_title} !"
embed = discord.Embed(
title=title,
url=link if link.startswith("http") else None,
color=color,
)
if description:
embed.description = description
# Prix / gratuit / validité (Discord : pas de couleur dans le texte, seulement **gras** / markdown)
value_parts = ["**Gratuit**"]
if valid_to:
try:
from datetime import datetime
end = datetime.fromisoformat(valid_to.replace("Z", "+00:00"))
value_parts.append(f"jusqu'au {end.strftime('%d/%m/%Y')}")
except Exception:
value_parts.append(f"jusqu'au {valid_to[:10]}")
embed.add_field(
name="Prix",
value="".join(value_parts),
inline=False,
)
recommended_price = extract_recommended_price_from_content(content_raw)
if recommended_price:
embed.add_field(name="Prix recommandé", value=recommended_price, inline=True)
genres = extract_genres_from_content(content_raw)
if genres:
embed.add_field(name="Genres", value=genres, inline=True)
rating = extract_rating_from_content(content_raw)
if rating:
embed.add_field(name="Ratings", value=rating, inline=True)
if link and link.startswith("http"):
embed.add_field(
name="\u200b",
value=f"[Ouvrir dans la boutique !]({link})",
inline=False,
)
# Thumbnail (logo de la boutique en haut à droite)
logo_url = SOURCE_LOGO_URLS.get(source_key)
if logo_url and logo_url.startswith("http"):
embed.set_thumbnail(url=logo_url)
# Image principale (style DraftBot)
if img_url and img_url.startswith("http"):
embed.set_image(url=img_url)
embed.set_footer(text="MamieHenriette • FreeLoot")
return embed
_freeloot_first_check = True
async def checkFreeLootAndNotify(bot: Client):
global _freeloot_first_check
helper = ConfigurationHelper()
if not helper.getValue("freeloot_enable"):
return
channel_id = helper.getIntValue("freeloot_channel_id")
if not channel_id:
return
channel = bot.get_channel(channel_id)
if not channel:
logging.warning("FreeLoot: canal Discord introuvable")
return
entries = fetch_feed()
if not entries:
return
# Au premier check après le démarrage, on synchronise sans notifier
if _freeloot_first_check:
logging.info("FreeLoot: première vérification, synchronisation sans notification")
for entry in entries:
entry_id = entry["id"]
if not FreeLootEntry.query.get(entry_id):
source_key = source_key_from_entry(entry["title"], entry["link"])
if source_key and _is_enabled_source(source_key):
try:
db.session.add(FreeLootEntry(entry_id=entry_id))
db.session.commit()
except Exception as e:
logging.error(f"FreeLoot: erreur de synchronisation pour {entry_id}: {e}")
db.session.rollback()
_freeloot_first_check = False
return
# Vérifications suivantes : notification normale
for entry in entries:
entry_id = entry["id"]
if FreeLootEntry.query.get(entry_id):
continue
source_key = source_key_from_entry(entry["title"], entry["link"])
if not source_key or not _is_enabled_source(source_key):
continue
try:
embed = _build_embed(entry, source_key)
content = _get_mention_content()
await channel.send(content=content or None, embed=embed)
db.session.add(FreeLootEntry(entry_id=entry_id))
db.session.commit()
except Exception as e:
logging.error(f"FreeLoot: envoi Discord échoué pour {entry_id}: {e}")
db.session.rollback()
async def _send_entry_to_discord_async(bot: Client, entry_id: str) -> tuple[bool, str]:
"""
Envoie une entrée FreeLoot sur Discord (appel manuel). Retourne (succès, message).
"""
channel_id = ConfigurationHelper().getIntValue("freeloot_channel_id")
if not channel_id:
return (False, "Aucun canal Discord configuré pour FreeLoot.")
channel = bot.get_channel(channel_id)
if not channel:
return (False, "Canal Discord introuvable.")
entries = fetch_feed()
if not entries:
return (False, "Impossible de charger le flux.")
entry = next((e for e in entries if e.get("id") == entry_id), None)
if not entry:
return (False, "Entrée introuvable dans le flux.")
source_key = source_key_from_entry(entry["title"], entry["link"])
if not source_key:
return (False, "Source non reconnue pour cette entrée.")
try:
embed = _build_embed(entry, source_key)
content = _get_mention_content()
await channel.send(content=content or None, embed=embed)
if not FreeLootEntry.query.get(entry_id):
db.session.add(FreeLootEntry(entry_id=entry_id))
db.session.commit()
return (True, "Annonce envoyée sur Discord.")
except Exception as e:
logging.error(f"FreeLoot: envoi manuel échoué pour {entry_id}: {e}")
db.session.rollback()
return (False, str(e))
def send_entry_to_discord_sync(bot: Client, entry_id: str) -> tuple[bool, str]:
"""Appel synchrone pour envoyer une entrée sur Discord (depuis la webapp)."""
try:
future = asyncio.run_coroutine_threadsafe(
_send_entry_to_discord_async(bot, entry_id),
bot.loop,
)
return future.result(timeout=15)
except Exception as e:
logging.error(f"FreeLoot: send_entry_to_discord_sync: {e}")
return (False, str(e))
+14
View File
@@ -8,6 +8,8 @@ from database.helpers import ConfigurationHelper
from database.models import GameBundle
from discord import Client
_humblebundle_first_check = True
def _isEnable():
helper = ConfigurationHelper()
@@ -40,10 +42,22 @@ def _formatMessage(bundle):
return message
async def checkHumbleBundleAndNotify(bot: Client):
global _humblebundle_first_check
if _isEnable() :
try :
bundles = _callGithub()
bundle = _findFirstNotNotified(bundles)
# Premier check : synchronisation sans notification
if _humblebundle_first_check:
if bundle != None:
logging.info(f'HumbleBundle: première vérification, synchronisation sans notification pour {bundle["name"]}')
db.session.add(GameBundle(url=bundle['url'], name=bundle['name'], json = json.dumps(bundle)))
db.session.commit()
_humblebundle_first_check = False
return
# Vérifications normales ensuite
if bundle != None :
message = _formatMessage(bundle)
await bot.get_channel(ConfigurationHelper().getIntValue('humble_bundle_channel')).send(message)
+546 -7
View File
@@ -4,12 +4,14 @@ import time
import os
import re
import discord
import io
from datetime import datetime, timezone, timedelta
from zoneinfo import ZoneInfo
from database import db
from database.helpers import ConfigurationHelper
from database.models import ModerationEvent
from discord import Message
from discord import Message, TextChannel, ForumChannel, Thread, app_commands
from discord.ui import Modal, TextInput, View, Select, ChannelSelect
def _get_local_tz():
tz_name = os.environ.get('APP_TZ') or os.environ.get('TZ') or 'Europe/Paris'
@@ -1055,7 +1057,15 @@ async def handle_staff_help_command(message: Message, bot):
value=(
"• `!say #channel message`\n"
" Envoie un message en tant que bot\n"
" Ex: `!say #annonces Nouvelle fonctionnalité !`"
" Ex: `!say #annonces Nouvelle fonctionnalité !`\n\n"
"• `!transfert #canal message_id [raison]`\n"
" Transfère un message vers un autre canal\n"
" *Alias: !transfer, !move*\n"
" Ex: `!transfert #entraide 123456789012345678`\n"
" Ex: `!transfert #général https://discord.com/channels/.../...`\n"
" Le message sera envoyé comme si c'était l'auteur original\n"
" Supporte les canaux textuels, threads et forums (crée un post)\n"
" Pour les forums, la raison devient le titre du post"
),
inline=False
)
@@ -1220,7 +1230,7 @@ async def parse_target_user(message: Message, bot, parts: list):
except (ValueError, discord.NotFound):
return None
def create_inspect_embed(user, member, join_date, days_on_server, account_age, warnings, kicks, bans, invite_info, requester):
def create_inspect_embed(user, member, join_date, days_on_server, account_age, warnings, kicks, bans, invite_info):
embed = discord.Embed(
title=f"🔍 Inspection de {user.name}",
color=discord.Color.blue(),
@@ -1228,7 +1238,7 @@ def create_inspect_embed(user, member, join_date, days_on_server, account_age, w
)
embed.set_thumbnail(url=user.display_avatar.url)
embed.add_field(name="👤 Utilisateur", value=f"**{user.name}**\n```{user.id}```", inline=True)
embed.add_field(name="👤 Utilisateur", value=f"**{user.name}**\n`{user.id}`", inline=True)
if account_age is not None:
embed.add_field(
@@ -1281,7 +1291,7 @@ def create_inspect_embed(user, member, join_date, days_on_server, account_age, w
else:
embed.add_field(name="✅ Historique de modération", value="Aucun incident", inline=False)
embed.set_footer(text=f"Demandé par {requester.name}")
embed.set_footer(text="Mamie Henriette")
return embed
async def get_invite_info_for_user(bot, guild, user_id: int):
@@ -1339,8 +1349,7 @@ async def handle_inspect_command(message: Message, bot):
warnings,
kicks,
bans,
invite_info,
message.author
invite_info
)
await message.channel.send(embed=embed)
@@ -1406,3 +1415,533 @@ async def handle_say_command(message: Message, bot):
except Exception as e:
logging.error(f"Erreur lors de l'envoi du message: {e}")
async def handle_transfer_command(message: Message, bot):
if not has_staff_role(message.author.roles):
await send_access_denied(message.channel)
return
parts = message.content.split(maxsplit=3)
if len(parts) < 3:
embed = discord.Embed(
title="📋 Utilisation de la commande",
description="**Syntaxe :** `!transfert #canal message_id [raison]` ou `!transfert #canal lien_message [raison]`",
color=discord.Color.blue()
)
embed.add_field(
name="Exemples",
value=(
"• `!transfert #entraide 123456789012345678`\n"
"• `!transfert #general https://discord.com/channels/.../...`\n"
"• `!transfert #entraide 123456789012345678 Message posté dans le mauvais canal`"
),
inline=False
)
embed.add_field(
name="Aliases",
value="`!transfert`, `!transfer`, `!move`",
inline=False
)
embed.add_field(
name="💡 Astuce",
value="Faites un clic droit sur un message → Copier l'identifiant du message, ou copiez le lien du message",
inline=False
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
target_channel = None
if message.channel_mentions:
target_channel = message.channel_mentions[0]
else:
try:
channel_id = int(parts[1].strip('<#>'))
target_channel = bot.get_channel(channel_id)
except ValueError:
pass
if not target_channel:
embed = discord.Embed(
title="❌ Erreur",
description="Canal de destination invalide. Mentionnez un canal avec #canal.",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
if not isinstance(target_channel, (TextChannel, ForumChannel, Thread)):
embed = discord.Embed(
title="❌ Erreur",
description=f"Le transfert n'est pas supporté vers ce type de canal. Utilisez un canal textuel, un forum ou un thread.",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
message_id = None
source_channel = message.channel
if 'discord.com/channels/' in parts[2]:
try:
link_parts = parts[2].split('/')
source_channel_id = int(link_parts[-2])
message_id = int(link_parts[-1])
source_channel = bot.get_channel(source_channel_id)
if not source_channel:
embed = discord.Embed(
title="❌ Erreur",
description="Canal source introuvable.",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
except (ValueError, IndexError):
embed = discord.Embed(
title="❌ Erreur",
description="Lien de message invalide.",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
else:
try:
message_id = int(parts[2])
except ValueError:
embed = discord.Embed(
title="❌ Erreur",
description="ID de message invalide. Utilisez un ID numérique ou un lien Discord.",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
try:
original_message = await source_channel.fetch_message(message_id)
except discord.NotFound:
embed = discord.Embed(
title="❌ Erreur",
description="Message introuvable. Vérifiez l'ID ou le lien.",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
except discord.Forbidden:
embed = discord.Embed(
title="❌ Erreur",
description="Je n'ai pas la permission d'accéder à ce message.",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
reason = parts[3] if len(parts) > 3 else "Message posté dans le mauvais canal"
content = original_message.content
embeds = original_message.embeds
files_to_send = []
for attachment in original_message.attachments:
try:
file_data = await attachment.read()
files_to_send.append(discord.File(
fp=io.BytesIO(file_data),
filename=attachment.filename
))
except Exception as e:
logging.error(f"Erreur lors du téléchargement de la pièce jointe: {e}")
transferred_message = None
if isinstance(target_channel, ForumChannel):
try:
post_title = f"{original_message.author.display_name} - "
if reason and reason != "Message posté dans le mauvais canal":
remaining_length = 100 - len(post_title)
post_title += reason[:remaining_length]
elif content and len(content) > 0:
remaining_length = 100 - len(post_title)
post_title += content[:remaining_length]
else:
post_title += "Message transféré"
if len(post_title) > 100:
post_title = post_title[:97] + "..."
transfer_notice = f"**Message original de {original_message.author.mention}**\n"
transfer_notice += f"*Ce message a été transféré par un membre du staff depuis {source_channel.mention}*\n"
transfer_notice += "" * 50 + "\n\n"
full_content = transfer_notice + (content or "")
thread = await target_channel.create_thread(
name=post_title,
content=full_content,
embeds=embeds[:10] if embeds else [],
files=files_to_send,
reason=f"Transfert depuis {source_channel.name} par {message.author.name}"
)
transferred_message = thread.message
except discord.HTTPException as e:
logging.error(f"Erreur lors de la création du post dans le forum: {e}")
embed = discord.Embed(
title="❌ Erreur",
description=f"Une erreur est survenue lors de la création du post dans le forum: {str(e)}",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
else:
webhooks = await target_channel.webhooks()
webhook = None
for wh in webhooks:
if wh.user == bot.user:
webhook = wh
break
if not webhook:
try:
webhook = await target_channel.create_webhook(name="Mamie Henriette - Transfert")
except discord.Forbidden:
embed = discord.Embed(
title="❌ Erreur",
description="Je n'ai pas la permission de créer un webhook dans le canal de destination.",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
try:
transferred_message = await webhook.send(
content=content,
username=original_message.author.display_name,
avatar_url=original_message.author.display_avatar.url,
embeds=embeds[:10] if embeds else [],
files=files_to_send,
allowed_mentions=discord.AllowedMentions.none(),
wait=True
)
except discord.HTTPException as e:
logging.error(f"Erreur lors du transfert du message: {e}")
embed = discord.Embed(
title="❌ Erreur",
description=f"Une erreur est survenue lors du transfert du message: {str(e)}",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
try:
await original_message.delete()
except discord.Forbidden:
logging.warning(f"Impossible de supprimer le message original (ID: {message_id})")
transfer_details = f"De {source_channel.name} vers {target_channel.name}"
if isinstance(target_channel, ForumChannel):
transfer_details += " (forum)"
transfer_event = ModerationEvent(
type='transfer',
username=original_message.author.name,
discord_id=str(original_message.author.id),
created_at=datetime.now(timezone.utc),
reason=f"{reason} | {transfer_details}",
staff_id=str(message.author.id),
staff_name=message.author.name
)
db.session.add(transfer_event)
_commit_with_retry()
local_now = _to_local(datetime.now(timezone.utc))
destination_info = target_channel.mention if isinstance(target_channel, (TextChannel, Thread)) else f"le forum {target_channel.name}"
embed = discord.Embed(
title="✅ Message transféré",
description=f"Le message de **{original_message.author.name}** a été transféré vers {destination_info}",
color=discord.Color.green(),
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="👤 Auteur original", value=f"{original_message.author.mention}", inline=True)
embed.add_field(name="📤 Canal source", value=source_channel.mention, inline=True)
embed.add_field(name="📥 Canal destination", value=destination_info, inline=True)
embed.add_field(name="🛡️ Modérateur", value=message.author.mention, inline=True)
embed.add_field(name="📝 Raison", value=reason, inline=False)
if isinstance(target_channel, ForumChannel):
embed.add_field(name="️ Type", value="Nouveau post créé dans le forum", inline=False)
confirmation_msg = await source_channel.send(embed=embed)
asyncio.create_task(delete_after_delay(confirmation_msg))
log_embed = discord.Embed(
title="📨 Transfert de message",
description=f"Un message de **{original_message.author.name}** a été transféré",
color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc)
)
log_embed.add_field(name="👤 Auteur original", value=f"{original_message.author.name}\n`{original_message.author.id}`", inline=True)
log_embed.add_field(name="🛡️ Modérateur", value=f"**{message.author.name}**", inline=True)
log_embed.add_field(name="📅 Date et heure", value=local_now.strftime('%d/%m/%Y à %H:%M'), inline=True)
log_embed.add_field(name="📤 De", value=source_channel.mention, inline=True)
log_embed.add_field(name="📥 Vers", value=f"{target_channel.name} ({type(target_channel).__name__})", inline=True)
log_embed.add_field(name="📝 Raison", value=reason, inline=False)
preview = content[:100] + "..." if content and len(content) > 100 else content
if preview:
log_embed.add_field(name="💬 Aperçu du message", value=preview, inline=False)
log_embed.set_footer(text=f"ID Auteur: {original_message.author.id} • Serveur: {message.guild.name}")
await send_to_moderation_log_channel(bot, log_embed)
await safe_delete_message(message)
class TransferReasonModal(Modal, title="Raison du transfert"):
reason = TextInput(
label="Raison / Titre du post (forum)",
placeholder="Pour les forums, ceci devient le titre du post",
required=False,
max_length=200,
style=discord.TextStyle.paragraph
)
def __init__(self, message: discord.Message, bot, target_channel, selected_tags=None):
super().__init__()
self.message = message
self.bot = bot
self.target_channel = target_channel
self.selected_tags = selected_tags or []
async def on_submit(self, interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
reason = self.reason.value.strip() if self.reason.value else "Message posté dans le mauvais canal"
target_channel = self.target_channel
source_channel = self.message.channel
content = self.message.content
embeds = self.message.embeds
files_to_send = []
for attachment in self.message.attachments:
try:
file_data = await attachment.read()
files_to_send.append(discord.File(
fp=io.BytesIO(file_data),
filename=attachment.filename
))
except Exception as e:
logging.error(f"Erreur lors du téléchargement de la pièce jointe: {e}")
transferred_message = None
if isinstance(target_channel, ForumChannel):
try:
post_title = f"{self.message.author.display_name} - "
if reason and reason != "Message posté dans le mauvais canal":
remaining_length = 100 - len(post_title)
post_title += reason[:remaining_length]
elif content and len(content) > 0:
remaining_length = 100 - len(post_title)
post_title += content[:remaining_length]
else:
post_title += "Message transféré"
if len(post_title) > 100:
post_title = post_title[:97] + "..."
transfer_notice = f"**Message original de {self.message.author.mention}**\n"
transfer_notice += f"*Ce message a été transféré par un membre du staff depuis {source_channel.mention}*\n"
transfer_notice += "" * 50 + "\n\n"
full_content = transfer_notice + (content or "")
thread = await target_channel.create_thread(
name=post_title,
content=full_content,
embeds=embeds[:10] if embeds else [],
files=files_to_send,
applied_tags=self.selected_tags,
reason=f"Transfert depuis {source_channel.name} par {interaction.user.name}"
)
transferred_message = thread.message
if self.selected_tags:
tag_names = ", ".join([tag.name for tag in self.selected_tags])
logging.info(f"Post créé avec les tags: {tag_names}")
except discord.HTTPException as e:
logging.error(f"Erreur lors de la création du post dans le forum: {e}")
await interaction.followup.send(f"❌ Erreur lors de la création du post: {str(e)}", ephemeral=True)
return
else:
webhooks = await target_channel.webhooks()
webhook = None
for wh in webhooks:
if wh.user == self.bot.user:
webhook = wh
break
if not webhook:
try:
webhook = await target_channel.create_webhook(name="Mamie Henriette - Transfert")
except discord.Forbidden:
await interaction.followup.send("❌ Je n'ai pas la permission de créer un webhook dans le canal de destination.", ephemeral=True)
return
try:
transferred_message = await webhook.send(
content=content,
username=self.message.author.display_name,
avatar_url=self.message.author.display_avatar.url,
embeds=embeds[:10] if embeds else [],
files=files_to_send,
allowed_mentions=discord.AllowedMentions.none(),
wait=True
)
except discord.HTTPException as e:
logging.error(f"Erreur lors du transfert du message: {e}")
await interaction.followup.send(f"❌ Erreur lors du transfert: {str(e)}", ephemeral=True)
return
try:
await self.message.delete()
except discord.Forbidden:
logging.warning(f"Impossible de supprimer le message original (ID: {self.message.id})")
transfer_details = f"De {source_channel.name} vers {target_channel.name}"
if isinstance(target_channel, ForumChannel):
transfer_details += " (forum)"
transfer_event = ModerationEvent(
type='transfer',
username=self.message.author.name,
discord_id=str(self.message.author.id),
created_at=datetime.now(timezone.utc),
reason=f"{reason} | {transfer_details}",
staff_id=str(interaction.user.id),
staff_name=interaction.user.name
)
db.session.add(transfer_event)
_commit_with_retry()
destination_info = target_channel.mention if isinstance(target_channel, (TextChannel, Thread)) else f"le forum {target_channel.name}"
await interaction.followup.send(
f"✅ Message de **{self.message.author.name}** transféré vers {destination_info}",
ephemeral=True
)
local_now = _to_local(datetime.now(timezone.utc))
log_embed = discord.Embed(
title="📨 Transfert de message",
description=f"Un message de **{self.message.author.name}** a été transféré",
color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc)
)
log_embed.add_field(name="👤 Auteur original", value=f"{self.message.author.name}\n`{self.message.author.id}`", inline=True)
log_embed.add_field(name="🛡️ Modérateur", value=f"**{interaction.user.name}**", inline=True)
log_embed.add_field(name="📅 Date et heure", value=local_now.strftime('%d/%m/%Y à %H:%M'), inline=True)
log_embed.add_field(name="📤 De", value=source_channel.mention, inline=True)
log_embed.add_field(name="📥 Vers", value=f"{target_channel.name} ({type(target_channel).__name__})", inline=True)
log_embed.add_field(name="📝 Raison", value=reason, inline=False)
preview = content[:100] + "..." if content and len(content) > 100 else content
if preview:
log_embed.add_field(name="💬 Aperçu du message", value=preview, inline=False)
log_embed.set_footer(text=f"ID Auteur: {self.message.author.id} • Serveur: {interaction.guild.name}")
await send_to_moderation_log_channel(self.bot, log_embed)
class ForumTagSelect(Select):
def __init__(self, message: discord.Message, bot, target_channel: ForumChannel):
options = []
for tag in target_channel.available_tags[:25]:
options.append(discord.SelectOption(
label=tag.name,
value=str(tag.id),
emoji=tag.emoji if tag.emoji else None
))
super().__init__(
placeholder="Sélectionnez un ou plusieurs tags...",
options=options,
min_values=1,
max_values=min(5, len(options))
)
self.message = message
self.bot = bot
self.target_channel = target_channel
async def callback(self, interaction: discord.Interaction):
selected_tags = []
for tag_id_str in self.values:
tag_id = int(tag_id_str)
tag = discord.utils.get(self.target_channel.available_tags, id=tag_id)
if tag:
selected_tags.append(tag)
modal = TransferReasonModal(self.message, self.bot, self.target_channel, selected_tags)
await interaction.response.send_modal(modal)
class ForumTagView(View):
def __init__(self, message: discord.Message, bot, target_channel: ForumChannel):
super().__init__(timeout=180)
self.add_item(ForumTagSelect(message, bot, target_channel))
class TransferChannelSelect(ChannelSelect):
def __init__(self, message: discord.Message, bot):
super().__init__(
placeholder="Sélectionnez le canal de destination...",
channel_types=[discord.ChannelType.text, discord.ChannelType.forum, discord.ChannelType.public_thread, discord.ChannelType.private_thread],
min_values=1,
max_values=1
)
self.message = message
self.bot = bot
async def callback(self, interaction: discord.Interaction):
selected_channel = self.values[0]
target_channel = self.bot.get_channel(selected_channel.id)
if not target_channel:
await interaction.response.send_message("❌ Impossible de récupérer le canal sélectionné.", ephemeral=True)
return
if isinstance(target_channel, ForumChannel) and target_channel.available_tags:
view = ForumTagView(self.message, self.bot, target_channel)
await interaction.response.send_message("🏷️ Sélectionnez un ou plusieurs tags pour ce post :", view=view, ephemeral=True)
else:
modal = TransferReasonModal(self.message, self.bot, target_channel)
await interaction.response.send_modal(modal)
class TransferView(View):
def __init__(self, message: discord.Message, bot):
super().__init__(timeout=180)
self.add_item(TransferChannelSelect(message, bot))
@app_commands.context_menu(name="Déplacer le message")
@app_commands.default_permissions(manage_messages=True)
async def transfer_message_context_menu(interaction: discord.Interaction, message: discord.Message):
if not has_staff_role(interaction.user.roles):
await interaction.response.send_message("❌ Vous n'avez pas les permissions nécessaires pour utiliser cette commande.", ephemeral=True)
return
view = TransferView(message, interaction.client)
await interaction.response.send_message("📨 Sélectionnez le canal de destination :", view=view, ephemeral=True)
+5 -3
View File
@@ -87,8 +87,6 @@ async def sendWelcomeMessage(bot: discord.Client, member: Member):
except Exception as e:
logging.error(f'Échec de la sauvegarde de l\'invitation : {e}')
account_age_days = (datetime.now(timezone.utc) - member.created_at).days
embed = discord.Embed(
title='🎉 Nouveau membre !',
description=welcome_message,
@@ -104,8 +102,12 @@ async def sendWelcomeMessage(bot: discord.Client, member: Member):
try:
message = await channel.send(embed=embed)
logging.info(f'Message de bienvenue envoyé pour {member.name}')
if account_age_days < 7:
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}')
+242
View File
@@ -0,0 +1,242 @@
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)
_youtube_first_check = True
async def checkYouTubeVideos():
global _youtube_first_check
with webapp.app_context():
try:
notifications: list[YouTubeNotification] = YouTubeNotification.query.filter_by(enable=True).all()
for notification in notifications:
try:
await _checkChannelVideos(notification, is_first_check=_youtube_first_check)
except Exception as e:
logger.error(f"Erreur lors de la vérification de la chaîne {notification.channel_id}: {e}")
continue
# Après la première vérification complète, on désactive le flag
if _youtube_first_check:
_youtube_first_check = False
logger.info("YouTube: première vérification terminée, notifications activées")
except Exception as e:
logger.error(f"Erreur lors de la vérification YouTube: {e}")
async def _checkChannelVideos(notification: YouTubeNotification, is_first_check: bool = False):
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]
# Si c'est la première vérification après démarrage, on synchronise sans notifier
if is_first_check:
if not notification.last_video_id or notification.last_video_id != latest_video_id:
logger.info(f"YouTube: synchronisation initiale pour {channel_id}, dernière vidéo: {latest_video_id}")
notification.last_video_id = latest_video_id
db.session.commit()
return
# Vérifications normales ensuite
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}")
+200
View File
@@ -0,0 +1,200 @@
# Module partagé : récupération et parsing du flux LootScraper (sans dépendance Discord)
import re
import xml.etree.ElementTree as ET
from html import unescape
import requests
FEED_URL = "https://feed.eikowagenknecht.com/lootscraper.xml"
ATOM_NS = {"atom": "http://www.w3.org/2005/Atom"}
SOURCES = [
("epic_pc", "Epic Games (PC)", "🖥️"),
("epic_android", "Epic Games (Android)", "🤖"),
("epic_ios", "Epic Games (iOS)", "🍎"),
("amazon_prime", "Amazon Prime Gaming", "📦"),
("gog", "GOG", "🎮"),
("google_play", "Google Play", "🤖"),
("apple_app_store", "Apple App Store", "🍎"),
]
def source_key_from_entry(title: str, link: str) -> str | None:
"""Détermine la clé source+plateforme depuis le titre et le lien."""
title_upper = (title or "").upper()
link_lower = (link or "").lower()
if "EPIC GAMES" in title_upper:
if "-ios-" in link_lower or "/ios-" in link_lower:
return "epic_ios"
if "-android-" in link_lower or "/android-" in link_lower:
return "epic_android"
return "epic_pc"
if "AMAZON PRIME" in title_upper:
return "amazon_prime"
if "GOG" in title_upper:
return "gog"
if "GOOGLE PLAY" in title_upper:
return "google_play"
if "APPLE APP STORE" in title_upper:
return "apple_app_store"
return None
def game_name_from_title(title: str) -> str:
"""Extrait le nom du jeu depuis le titre."""
if not title:
return "Jeu gratuit"
for prefix in (
"Epic Games (Game) - ",
"Amazon Prime (Game) - ",
"GOG (Game) - ",
"GOG (Game, Always Free) - ",
"Google Play (Game) - ",
"Apple App Store (Game) - ",
):
if title.startswith(prefix):
return unescape(title[len(prefix) :].strip())
if " - " in title:
return unescape(title.split(" - ", 1)[1].strip())
return unescape(title.strip())
def extract_image_from_content(content: str) -> str | None:
"""Extrait l'URL de la première image du contenu HTML (toutes balises img)."""
if not content:
return None
def _normalize_url(url: str) -> str:
u = unescape(url.strip()).replace("&amp;", "&")
return u
# 1) Balises <img ... src="url" ...>
for m in re.finditer(r'<img[^>]+src\s*=\s*["\']([^"\']+)["\']', content, re.I):
url = _normalize_url(m.group(1))
if url.startswith("http"):
return url
# 2) Fallback: toute attribution src="http..." (certains CDN n'ont pas d'extension)
for m in re.finditer(r'src\s*=\s*["\'](https?://[^"\']{20,})["\']', content, re.I):
url = _normalize_url(m.group(1))
if url.startswith("http"):
return url
return None
def extract_description_from_content(content: str, max_len: int = 400) -> str | None:
"""Extrait la description du jeu depuis le contenu HTML (balise <b>Description:</b>)."""
if not content:
return None
m = re.search(r"<b>Description:</b>\s*([^<]+)", content, re.I | re.DOTALL)
if not m:
return None
desc = unescape(m.group(1).strip())
desc = re.sub(r"\s+", " ", desc)
if len(desc) > max_len:
desc = desc[: max_len - 3].rsplit(" ", 1)[0] + "..."
return desc or None
def extract_valid_to_from_content(content: str) -> str | None:
"""Extrait la date 'Offer valid to' depuis le contenu HTML."""
if not content:
return None
m = re.search(r"<b>Offer valid to:</b>\s*(\d{4}-\d{2}-\d{2}[^<]*)", content, re.I)
return m.group(1).strip() if m else None
def extract_recommended_price_from_content(content: str) -> str | None:
"""Extrait le prix recommandé (ex: '39.99 EUR') depuis le contenu HTML."""
if not content:
return None
m = re.search(r"<b>Recommended price\s*\([^)]*\):\s*</b>\s*([^<]+)", content, re.I)
if not m:
m = re.search(r"Recommended price[^<]*</b>\s*([^<]+)", content, re.I)
return unescape(m.group(1).strip()) if m else None
def extract_genres_from_content(content: str) -> str | None:
"""Extrait les genres (ex: 'Action, Indie') depuis le contenu HTML."""
if not content:
return None
m = re.search(r"<b>Genres:</b>\s*([^<]+)", content, re.I)
return unescape(m.group(1).strip()) if m else None
def extract_rating_from_content(content: str) -> str | None:
"""Extrait le rating (texte après Ratings:, liens ou texte brut)."""
if not content:
return None
m = re.search(r"<b>Ratings:</b>\s*(.+?)</li>", content, re.I | re.DOTALL)
if not m:
return None
raw = m.group(1)
raw = re.sub(r"<a[^>]*>([^<]*)</a>", r"\1", raw)
raw = re.sub(r"<[^>]+>", " ", raw)
raw = unescape(re.sub(r"\s+", " ", raw).strip())
return raw if len(raw) > 0 and len(raw) < 200 else None
def fetch_feed() -> list[dict] | None:
"""Récupère et parse le flux Atom, retourne une liste d'entrées brutes."""
try:
r = requests.get(FEED_URL, timeout=15)
r.raise_for_status()
root = ET.fromstring(r.content)
entries = []
for entry_el in root.findall(".//atom:entry", ATOM_NS):
entry_id_el = entry_el.find("atom:id", ATOM_NS)
title_el = entry_el.find("atom:title", ATOM_NS)
link_el = entry_el.find("atom:link", ATOM_NS)
content_el = entry_el.find("atom:content", ATOM_NS)
updated_el = entry_el.find("atom:updated", ATOM_NS) or entry_el.find("atom:published", ATOM_NS)
entry_id = entry_id_el.text.strip() if entry_id_el is not None and entry_id_el.text else None
title = title_el.text.strip() if title_el is not None and title_el.text else None
link = link_el.get("href") if link_el is not None else None
content = ""
if content_el is not None:
# Sérialiser tout le sous-arbre pour ne rien perdre (notamment les <img>)
content = ET.tostring(content_el, encoding="unicode", method="xml")
# Normaliser les préfixes de namespace (ex: <html:b> -> <b>) pour que les regex d'extraction matchent
content = content.replace("</html:", "</").replace("<html:", "<")
updated = updated_el.text.strip() if updated_el is not None and updated_el.text else None
if entry_id and title:
entries.append({
"id": entry_id,
"title": title,
"link": link or "",
"content": content or "",
"updated": updated,
})
return entries
except Exception:
return None
def get_display_entries() -> list[dict]:
"""Retourne la liste des entrées formatées pour affichage (webapp)."""
raw = fetch_feed()
if not raw:
return []
result = []
for e in raw:
sk = source_key_from_entry(e["title"], e["link"])
content = e.get("content") or ""
desc = extract_description_from_content(content)
result.append({
"id": e["id"],
"title": e["title"],
"link": e["link"],
"game_name": game_name_from_title(e["title"]),
"source_key": sk or "other",
"source_label": next((s[1] for s in SOURCES if s[0] == sk), sk or "Autre"),
"emoji": next((s[2] for s in SOURCES if s[0] == sk), "🎁"),
"image_url": extract_image_from_content(content),
"description": desc,
"valid_to": extract_valid_to_from_content(content),
"recommended_price": extract_recommended_price_from_content(content),
"genres": extract_genres_from_content(content),
"rating": extract_rating_from_content(content),
"updated": e.get("updated"),
})
return result
+1 -3
View File
@@ -11,6 +11,7 @@ twitchAPI>=4.5.0
# Nécessaire pour l'hébergement du site web
flask>=2.3.2
flask-login>=0.6.3
flask-sqlalchemy>=3.0.3
flask[async]
waitress>=3.0.2
@@ -21,6 +22,3 @@ requests>=2.32.4
# Nécessaire pour l'appel à ProtonDB
algoliasearch>=4,<5
# Nécessaire pour le flux RSS des jeux gratuits
feedparser>=6.0.0
-241
View File
@@ -1,241 +0,0 @@
import threading
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Callable
from datetime import datetime
@dataclass
class BotStats:
"""Statistiques des bots"""
# Discord
discord_connected: bool = False
discord_guilds: int = 0
discord_members: int = 0
discord_channels: int = 0
discord_bot_name: str = ""
discord_bot_id: int = 0
# Twitch
twitch_connected: bool = False
twitch_channel: str = ""
# Cogs/Fonctionnalités activées
cogs_enabled: Dict[str, bool] = field(default_factory=dict)
# Dernière mise à jour
last_update: datetime = None
class StatsManager:
"""Gestionnaire thread-safe des statistiques"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._stats = BotStats()
cls._instance._stats_lock = threading.Lock()
return cls._instance
def update_discord_stats(self, connected: bool = None, guilds: int = None,
members: int = None, channels: int = None,
bot_name: str = None, bot_id: int = None):
"""Met à jour les stats Discord"""
with self._stats_lock:
if connected is not None:
self._stats.discord_connected = connected
if guilds is not None:
self._stats.discord_guilds = guilds
if members is not None:
self._stats.discord_members = members
if channels is not None:
self._stats.discord_channels = channels
if bot_name is not None:
self._stats.discord_bot_name = bot_name
if bot_id is not None:
self._stats.discord_bot_id = bot_id
self._stats.last_update = datetime.now()
def update_twitch_stats(self, connected: bool = None, channel: str = None):
"""Met à jour les stats Twitch"""
with self._stats_lock:
if connected is not None:
self._stats.twitch_connected = connected
if channel is not None:
self._stats.twitch_channel = channel
self._stats.last_update = datetime.now()
def update_cogs(self, cogs: Dict[str, bool]):
"""Met à jour les cogs activés"""
with self._stats_lock:
self._stats.cogs_enabled = cogs.copy()
self._stats.last_update = datetime.now()
def get_stats(self) -> BotStats:
"""Retourne une copie des stats"""
with self._stats_lock:
return BotStats(
discord_connected=self._stats.discord_connected,
discord_guilds=self._stats.discord_guilds,
discord_members=self._stats.discord_members,
discord_channels=self._stats.discord_channels,
discord_bot_name=self._stats.discord_bot_name,
discord_bot_id=self._stats.discord_bot_id,
twitch_connected=self._stats.twitch_connected,
twitch_channel=self._stats.twitch_channel,
cogs_enabled=self._stats.cogs_enabled.copy(),
last_update=self._stats.last_update
)
class DiscordBridge:
"""
Pont de communication entre Flask et le bot Discord.
Permet d'exécuter des actions Discord depuis Flask de manière thread-safe.
"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._bot = None
cls._instance._loop = None
return cls._instance
def register_bot(self, bot, loop):
"""Enregistre le bot Discord et sa boucle événementielle"""
with self._lock:
self._bot = bot
self._loop = loop
logging.info("Bot Discord enregistré dans le bridge")
def is_ready(self) -> bool:
"""Vérifie si le bot est prêt"""
return self._bot is not None and self._loop is not None and not self._loop.is_closed()
def get_text_channels(self) -> List[Dict]:
"""Retourne la liste des canaux texte disponibles"""
if not self.is_ready():
return []
channels = []
try:
for guild in self._bot.guilds:
for channel in guild.text_channels:
channels.append({
'id': channel.id,
'name': channel.name,
'guild_name': guild.name,
'guild_id': guild.id
})
except Exception as e:
logging.error(f"Erreur lors de la récupération des canaux: {e}")
return channels
def send_message(self, channel_id: int, message: str) -> tuple[bool, str]:
"""
Envoie un message dans un canal Discord.
Retourne (succès, message d'erreur ou de confirmation)
"""
if not self.is_ready():
return False, "Le bot Discord n'est pas connecté"
if not message or not message.strip():
return False, "Le message ne peut pas être vide"
try:
future = asyncio.run_coroutine_threadsafe(
self._send_message_async(channel_id, message),
self._loop
)
# Attendre le résultat avec timeout de 10 secondes
return future.result(timeout=10)
except asyncio.TimeoutError:
return False, "Timeout lors de l'envoi du message"
except Exception as e:
logging.error(f"Erreur lors de l'envoi du message: {e}")
return False, f"Erreur: {str(e)}"
async def _send_message_async(self, channel_id: int, message: str) -> tuple[bool, str]:
"""Coroutine interne pour envoyer le message"""
try:
channel = self._bot.get_channel(channel_id)
if not channel:
return False, "Canal introuvable"
await channel.send(message)
return True, f"Message envoyé dans #{channel.name}"
except Exception as e:
return False, f"Erreur: {str(e)}"
def sync_invites(self, guild_id: int = None) -> dict:
"""Synchronise les invitations Discord avec la base de données"""
if not self.is_ready():
return {'success': False, 'message': "Le bot Discord n'est pas connecté", 'synced': 0}
try:
future = asyncio.run_coroutine_threadsafe(
self._bot.syncInvites(guild_id),
self._loop
)
result = future.result(timeout=30)
result['success'] = True
return result
except asyncio.TimeoutError:
return {'success': False, 'message': "Timeout lors de la synchronisation", 'synced': 0}
except Exception as e:
logging.error(f"Erreur lors de la synchronisation des invitations: {e}")
return {'success': False, 'message': f"Erreur: {str(e)}", 'synced': 0}
def revoke_invite(self, invite_code: str) -> dict:
"""Révoque une invitation Discord"""
if not self.is_ready():
return {'success': False, 'message': "Le bot Discord n'est pas connecté"}
try:
future = asyncio.run_coroutine_threadsafe(
self._bot.revokeInvite(invite_code),
self._loop
)
return future.result(timeout=10)
except asyncio.TimeoutError:
return {'success': False, 'message': "Timeout lors de la révocation"}
except Exception as e:
logging.error(f"Erreur lors de la révocation de l'invitation: {e}")
return {'success': False, 'message': f"Erreur: {str(e)}"}
def get_invites(self, guild_id: int = None, include_revoked: bool = False) -> list:
"""Récupère les invitations depuis la base de données"""
if not self.is_ready():
return []
try:
return self._bot.getInvites(guild_id, include_revoked)
except Exception as e:
logging.error(f"Erreur lors de la récupération des invitations: {e}")
return []
def get_guilds(self) -> list:
"""Retourne la liste des guilds"""
if not self.is_ready():
return []
try:
return self._bot.getAllGuilds()
except Exception as e:
logging.error(f"Erreur lors de la récupération des guilds: {e}")
return []
# Instances globales
stats_manager = StatsManager()
discord_bridge = DiscordBridge()
+214 -28
View File
@@ -1,4 +1,3 @@
import asyncio
import logging
@@ -7,69 +6,256 @@ from twitchAPI.type import AuthScope, ChatEvent
from twitchAPI.chat import Chat, ChatEvent, ChatMessage, EventData
from database.helpers import ConfigurationHelper
from twitchbot.live_alert import checkOnlineStreamer
from webapp import webapp
from shared_stats import stats_manager
from database.models import Commande
def _user_has_twitch_permission(msg: ChatMessage, required: str) -> bool:
"""Vérifie si l'utilisateur a le niveau de permission requis (viewer, sub, vip, moderator)."""
if not required or required == 'viewer':
return True
is_broadcaster = msg.user.name.lower() == msg.room.name.lower()
is_mod = msg.user.mod or is_broadcaster
if required == 'moderator':
return is_mod
if required == 'vip':
return msg.user.vip or is_mod
if required == 'sub':
return msg.user.subscriber or msg.user.vip or is_mod
return True
from twitchbot.live_alert import checkOnlineStreamer
from twitchbot.announcements import checkAndSendAnnouncements, incrementMessageCount
from twitchbot import moderation
from twitchbot import link_filter
from twitchbot import event_notifications
from webapp import webapp
USER_SCOPE = [
AuthScope.CHAT_READ,
AuthScope.CHAT_EDIT,
AuthScope.MODERATOR_MANAGE_BANNED_USERS,
AuthScope.MODERATOR_MANAGE_CHAT_MESSAGES,
AuthScope.MODERATOR_MANAGE_CHAT_SETTINGS,
AuthScope.MODERATOR_MANAGE_SHIELD_MODE,
AuthScope.CHANNEL_MANAGE_BROADCAST,
AuthScope.MODERATOR_READ_FOLLOWERS,
AuthScope.CHANNEL_READ_SUBSCRIPTIONS, # EventSub channel.subscribe (notifs sub)
]
USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT]
async def _onReady(ready_event: EventData):
logging.info('Bot Twitch prêt')
with webapp.app_context():
channel = ConfigurationHelper().getValue('twitch_channel')
webapp.config["BOT_STATUS"]["twitch_connected"] = True
webapp.config["BOT_STATUS"]["twitch_channel_name"] = channel
await ready_event.chat.join_room(channel)
stats_manager.update_twitch_stats(connected=True, channel=channel)
# EventSub (follow, sub, raid) : besoin du broadcaster_id
try:
from twitchAPI.helper import first
user = await first(twitchBot.twitch.get_users(logins=[channel]))
if user:
twitchBot._eventsub = event_notifications.create_eventsub(twitchBot.twitch, asyncio.get_event_loop())
asyncio.create_task(event_notifications.register_eventsub_handlers(twitchBot._eventsub, user.id, ready_event.chat, channel))
except Exception as e:
logging.warning('EventSub non démarré: %s', e)
asyncio.get_event_loop().create_task(twitchBot._checkOnlineStreamers())
asyncio.get_event_loop().create_task(twitchBot._runAnnouncements())
asyncio.get_event_loop().create_task(twitchBot._checkClips())
async def _onMessage(msg: ChatMessage):
logging.info(f'Dans {msg.room.name}, {msg.user.name} a dit : {msg.text}')
incrementMessageCount()
# Stocker le message dans BOT_STATUS pour l'affichage web
with webapp.app_context():
from datetime import datetime
message_data = {
'username': msg.user.name,
'text': msg.text,
'timestamp': datetime.now().isoformat(),
'is_mod': msg.user.mod,
'is_subscriber': msg.user.subscriber,
'is_vip': msg.user.vip,
'color': getattr(msg.user, 'color', None) or '#9146FF'
}
messages = webapp.config["BOT_STATUS"]["twitch_chat_messages"]
messages.append(message_data)
# Limiter à 100 messages
if len(messages) > 100:
messages.pop(0)
if not await link_filter.check_message_for_links(msg, twitchBot.twitch):
return
if not await moderation.check_message_for_banned_words(msg, twitchBot.twitch):
return
await _handleCustomCommand(msg)
async def _handleCustomCommand(msg: ChatMessage):
if not msg.text.startswith('!'):
return
trigger = msg.text.split()[0].lower()
with webapp.app_context():
# Vérifier si les commandes Twitch sont activées globalement
if not ConfigurationHelper().getValue('twitch_commands_enable'):
return
commande = Commande.query.filter_by(trigger=trigger, twitch_enable=True).first()
if commande:
permission = commande.twitch_permission or 'viewer'
if not _user_has_twitch_permission(msg, permission):
return # Pas de réponse = l'utilisateur n'a pas la permission
response = commande.response.replace('{user}', msg.user.name)
await msg.reply(response)
# commande qui répond "bonjour" à "!hello"
async def _helloCommand(msg: ChatMessage):
await msg.reply(f'Bonjour {msg.user.name}')
def _isConfigured() -> bool:
helper = ConfigurationHelper()
return helper.getValue('twitch_client_id') != None and helper.getValue('twitch_client_secret') != None and helper.getValue('twitch_access_token') != None and helper.getValue('twitch_refresh_token') != None and helper.getValue('twitch_channel') != None
class TwitchBot() :
def _isConfigured():
helper = ConfigurationHelper()
return (helper.getValue('twitch_client_id') is not None and
helper.getValue('twitch_client_secret') is not None and
helper.getValue('twitch_access_token') is not None and
helper.getValue('twitch_refresh_token') is not None and
helper.getValue('twitch_channel') is not None)
class TwitchBot():
_eventsub = None
async def _connect(self):
with webapp.app_context():
if _isConfigured() :
try :
if _isConfigured():
try:
helper = ConfigurationHelper()
self.twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
await self.twitch.set_user_authentication(helper.getValue('twitch_access_token'), USER_SCOPE, helper.getValue('twitch_refresh_token'))
self.chat = await Chat(self.twitch)
self.chat.register_event(ChatEvent.READY, _onReady)
self.chat.register_event(ChatEvent.MESSAGE, _onMessage)
# chat.register_event(ChatEvent.SUB, on_sub)
self.chat.register_command('hello', _helloCommand)
self._register_moderation_commands()
self.chat.start()
except Exception as e:
logging.error(f'Échec de l\'authentification Twitch. Vérifiez vos identifiants et redémarrez après correction : {e}')
else:
except Exception as e:
logging.error(f'Échec de l\'authentification Twitch : {e}')
else:
logging.info("Twitch n'est pas configuré")
async def _checkOnlineStreamers(self):
# pas bon faudrait faire un truc mieux
while True :
def _register_moderation_commands(self):
# Créer des wrappers async pour chaque commande
async def cmd_timeout(msg): await moderation.timeout_command(msg, self.twitch)
async def cmd_ban(msg): await moderation.ban_command(msg, self.twitch)
async def cmd_unban(msg): await moderation.unban_command(msg, self.twitch)
async def cmd_clean(msg): await moderation.clean_command(msg, self.twitch)
async def cmd_shieldmode(msg): await moderation.shieldmode_command(msg, self.twitch)
async def cmd_settitle(msg): await moderation.settitle_command(msg, self.twitch)
async def cmd_setgame(msg): await moderation.setgame_command(msg, self.twitch)
async def cmd_subon(msg): await moderation.subon_command(msg, self.twitch)
async def cmd_suboff(msg): await moderation.suboff_command(msg, self.twitch)
async def cmd_follon(msg): await moderation.follon_command(msg, self.twitch)
async def cmd_folloff(msg): await moderation.folloff_command(msg, self.twitch)
async def cmd_emoteon(msg): await moderation.emoteon_command(msg, self.twitch)
async def cmd_emoteoff(msg): await moderation.emoteoff_command(msg, self.twitch)
async def cmd_ann(msg): await moderation.ann_command(msg, self.twitch)
async def cmd_no_game(msg): await moderation.no_game_command(msg, self.twitch)
async def cmd_multitwitch(msg): await moderation.multitwitch_command(msg, self.twitch)
async def cmd_permit(msg): await link_filter.permit_command(msg, self.twitch)
self.chat.register_command('kick', cmd_timeout)
self.chat.register_command('to', cmd_timeout)
self.chat.register_command('timeout', cmd_timeout)
self.chat.register_command('tm', cmd_timeout)
self.chat.register_command('ban', cmd_ban)
self.chat.register_command('unban', cmd_unban)
self.chat.register_command('clean', cmd_clean)
self.chat.register_command('shieldmode', cmd_shieldmode)
self.chat.register_command('settitle', cmd_settitle)
self.chat.register_command('setgame', cmd_setgame)
self.chat.register_command('setcateg', cmd_setgame)
self.chat.register_command('subon', cmd_subon)
self.chat.register_command('suboff', cmd_suboff)
self.chat.register_command('follon', cmd_follon)
self.chat.register_command('folloff', cmd_folloff)
self.chat.register_command('emoteon', cmd_emoteon)
self.chat.register_command('emoteoff', cmd_emoteoff)
self.chat.register_command('ann', cmd_ann)
self.chat.register_command('no_game', cmd_no_game)
self.chat.register_command('multitwitch', cmd_multitwitch)
self.chat.register_command('permit', cmd_permit)
async def _checkOnlineStreamers(self):
while True:
try:
await checkOnlineStreamer(self.twitch)
except Exception as e:
logging.error(f'Erreur lors lors du check des streamers online : {e}')
# toutes les 5 minutes
await asyncio.sleep(5*60)
logging.error(f'Erreur check streamers online : {e}')
await asyncio.sleep(5 * 60)
def begin(self):
async def _runAnnouncements(self):
with webapp.app_context():
channel = ConfigurationHelper().getValue('twitch_channel')
while True:
try:
await checkAndSendAnnouncements(self.chat, channel, self.twitch)
except Exception as e:
logging.error(f'Erreur envoi annonces : {e}')
await asyncio.sleep(2 * 60) # Vérification toutes les 2 min pour limiter le spam
async def _checkClips(self):
"""Vérifie le clip le plus récent (polling) et notifie si nouveau."""
with webapp.app_context():
channel = ConfigurationHelper().getValue('twitch_channel')
if not channel:
return
from twitchAPI.helper import first
try:
user = await first(self.twitch.get_users(logins=[channel]))
if not user:
return
except Exception:
return
while True:
try:
with webapp.app_context():
from database.models import TwitchEventNotification
cfg = TwitchEventNotification.query.filter_by(event_type='clip', enable=True).first()
if not cfg:
await asyncio.sleep(120)
continue
clip = await first(self.twitch.get_clips(broadcaster_id=user.id, first=1))
if not clip:
await asyncio.sleep(120)
continue
if cfg.last_clip_id is None:
with webapp.app_context():
from database import db
cfg = TwitchEventNotification.query.filter_by(event_type='clip', enable=True).first()
if cfg:
cfg.last_clip_id = clip.id
db.session.commit()
elif clip.id != cfg.last_clip_id:
with webapp.app_context():
await event_notifications.notify_clip(
self.chat,
channel,
user=getattr(clip, 'creator_name', None) or getattr(clip, 'user_name', '') or clip.id,
title=getattr(clip, 'title', '') or 'Clip',
url=getattr(clip, 'url', '') or f'https://clips.twitch.tv/{clip.id}',
thumbnail_url=getattr(clip, 'thumbnail_url', '') or '',
clip_id=clip.id,
)
except Exception as e:
logging.error('Erreur check clips: %s', e)
await asyncio.sleep(120)
def begin(self):
asyncio.run(self._connect())
# je ne sais pas encore comment appeler ça
async def _close(self):
self.chat.stop()
await self.twitch.close()
twitchBot = TwitchBot()
twitchBot = TwitchBot()
+137
View File
@@ -0,0 +1,137 @@
import logging
from datetime import datetime, timedelta
from twitchAPI.chat import Chat
from twitchAPI.twitch import Twitch
from database import db
from database.models import TwitchAnnouncement
from webapp import webapp
logger = logging.getLogger('twitch-announcements')
logger.setLevel(logging.INFO)
# Délai minimum entre deux annonces (quelle qu'elles soient) pour éviter le spam
MIN_DELAY_BETWEEN_ANNOUNCEMENTS_MINUTES = 5
_message_count: int = 0
_last_announcement_index: int = -1 # Pour la rotation round-robin
def incrementMessageCount():
global _message_count
_message_count += 1
def _getAndResetMessageCount() -> int:
global _message_count
count = _message_count
_message_count = 0
return count
async def _is_channel_live(twitch: Twitch, channel: str) -> bool:
"""Vérifie si la chaîne Twitch est actuellement en live."""
try:
async for _ in twitch.get_streams(user_login=[channel]):
return True
except Exception as e:
logger.warning(f'Impossible de vérifier le statut live de {channel}: {e}')
return False
async def checkAndSendAnnouncements(chat: Chat, channel: str, twitch: Twitch):
"""
Envoie une annonce en rotation parmi celles dont la périodicité est écoulée,
uniquement si la chaîne est en live et qu'il y a assez d'activité dans le chat.
Appelé périodiquement par le bot Twitch.
"""
global _last_announcement_index
with webapp.app_context():
if not await _is_channel_live(twitch, channel):
return
announcements: list[TwitchAnnouncement] = TwitchAnnouncement.query.filter_by(enable=True).order_by(TwitchAnnouncement.id).all()
if not announcements:
return
now = datetime.now()
# Ne pas envoyer si une annonce (quelle qu'elle soit) a été envoyée récemment
last_any = max((a.last_sent for a in announcements if a.last_sent), default=None)
if last_any and (now - last_any) < timedelta(minutes=MIN_DELAY_BETWEEN_ANNOUNCEMENTS_MINUTES):
return
# Filtrer les annonces dont la périodicité est écoulée
due = [a for a in announcements if _shouldSend(a, now)]
if not due:
return
# Rotation round-robin : chercher la prochaine annonce après la dernière envoyée
announcement = _selectNextAnnouncement(due, announcements)
if not announcement:
return
# Vérifier le nombre minimum de messages dans le chat
message_count = _getAndResetMessageCount()
if message_count < announcement.min_chat_messages:
logger.debug(f'Annonce "{announcement.name}" ignorée : seulement {message_count} messages (minimum requis : {announcement.min_chat_messages})')
return
try:
await _sendAnnouncement(chat, channel, announcement)
announcement.last_sent = now
_last_announcement_index = announcements.index(announcement)
db.session.commit()
logger.info(f'Annonce envoyée : {announcement.name} (après {message_count} messages)')
except Exception as e:
logger.error(f'Erreur lors de l\'envoi de l\'annonce "{announcement.name}": {e}')
def _selectNextAnnouncement(due: list[TwitchAnnouncement], all_announcements: list[TwitchAnnouncement]) -> TwitchAnnouncement | None:
"""
Sélectionne la prochaine annonce selon un système de rotation round-robin.
Cherche la première annonce éligible après la dernière envoyée.
"""
global _last_announcement_index
if not due:
return None
# Si c'est la première annonce ou si l'index est invalide, prendre la première de la liste
if _last_announcement_index == -1 or _last_announcement_index >= len(all_announcements):
return due[0]
# Chercher la prochaine annonce éligible après la dernière envoyée
start_index = _last_announcement_index + 1
# Parcourir depuis l'index suivant jusqu'à la fin, puis revenir au début
for i in range(len(all_announcements)):
idx = (start_index + i) % len(all_announcements)
announcement = all_announcements[idx]
if announcement in due:
return announcement
# Fallback : retourner la première annonce éligible
return due[0]
def _shouldSend(announcement: TwitchAnnouncement, now: datetime) -> bool:
"""
Vérifie si une annonce doit être envoyée basée sur sa périodicité.
"""
if announcement.last_sent is None:
return True
time_since_last = now - announcement.last_sent
periodicity_delta = timedelta(minutes=announcement.periodicity)
return time_since_last >= periodicity_delta
async def _sendAnnouncement(chat: Chat, channel: str, announcement: TwitchAnnouncement):
"""
Envoie une annonce dans le chat Twitch.
"""
await chat.send_message(channel, announcement.text)
+264
View File
@@ -0,0 +1,264 @@
# Notifications d'événements Twitch (sub, follow, raid, clip) : chat + Discord
import asyncio
import logging
from typing import Any
import discord
from twitchAPI.chat import Chat
from twitchAPI.eventsub.websocket import EventSubWebsocket
from twitchAPI.object.eventsub import (
ChannelFollowEvent,
ChannelRaidEvent,
ChannelSubscribeEvent,
)
from twitchAPI.twitch import Twitch
from database import db
from database.models import TwitchEventNotification
from webapp import webapp
logger = logging.getLogger("twitch-events")
def _format_message(template: str, **kwargs: Any) -> str:
if not template:
return ""
for k, v in (kwargs or {}).items():
template = template.replace("{" + k + "}", str(v or ""))
return template
async def _send_twitch(chat: Chat, channel: str, text: str) -> None:
if not text or not chat:
return
try:
await chat.send_message(channel, text[:500])
except Exception as e:
logger.error("Envoi chat Twitch événement: %s", e)
def _schedule_discord_send(channel_id: int, content: str | None, embed: discord.Embed | None) -> None:
"""Planifie l'envoi sur le canal Discord (sans bloquer le loop Twitch)."""
from discordbot import bot
try:
ch = bot.get_channel(channel_id)
if not ch:
logger.warning("Canal Discord %s introuvable", channel_id)
return
payload = content if content else embed
if not payload:
return
asyncio.run_coroutine_threadsafe(
ch.send(content=content, embed=embed) if (content and embed) else ch.send(content=content or None, embed=embed if not content else None),
bot.loop,
)
except Exception as e:
logger.error("Envoi Discord événement: %s", e)
async def _handle_follow(data: ChannelFollowEvent, chat: Chat, channel: str) -> None:
with webapp.app_context():
cfg = TwitchEventNotification.query.filter_by(event_type="follow", enable=True).first()
if not cfg:
return
ev = data.event
user = getattr(ev, "user_name", None) or getattr(ev, "user_login", "")
user_login = getattr(ev, "user_login", user)
msg = _format_message(
cfg.message_twitch or "Merci {user} pour le follow !",
user=user_login,
user_name=user,
)
if cfg.notify_twitch_chat and msg:
await _send_twitch(chat, channel, msg)
if cfg.notify_discord and cfg.discord_channel_id:
content = _format_message(cfg.message_discord or "", user=user_login, user_name=user)
try:
embed_color = int(cfg.embed_color or "9146FF", 16)
except ValueError:
embed_color = 0x9146FF
embed = discord.Embed(
title=_format_message(cfg.embed_title or "Nouveau follow", user=user_login, user_name=user),
description=cfg.embed_description or f"{user} suit maintenant la chaîne.",
color=embed_color,
)
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
async def _handle_subscribe(data: ChannelSubscribeEvent, chat: Chat, channel: str) -> None:
with webapp.app_context():
cfg = TwitchEventNotification.query.filter_by(event_type="sub", enable=True).first()
if not cfg:
return
ev = data.event
user = getattr(ev, "user_name", None) or getattr(ev, "user_login", "")
user_login = getattr(ev, "user_login", user)
msg = _format_message(
cfg.message_twitch or "Merci {user} pour l'abonnement !",
user=user_login,
user_name=user,
)
if cfg.notify_twitch_chat and msg:
await _send_twitch(chat, channel, msg)
if cfg.notify_discord and cfg.discord_channel_id:
content = _format_message(cfg.message_discord or "", user=user_login, user_name=user)
try:
embed_color = int(cfg.embed_color or "9146FF", 16)
except ValueError:
embed_color = 0x9146FF
embed = discord.Embed(
title=_format_message(cfg.embed_title or "Nouvel abonnement", user=user_login, user_name=user),
description=cfg.embed_description or f"Merci à {user} pour l'abonnement !",
color=embed_color,
)
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
async def _handle_raid(data: ChannelRaidEvent, chat: Chat, channel: str) -> None:
with webapp.app_context():
cfg = TwitchEventNotification.query.filter_by(event_type="raid", enable=True).first()
if not cfg:
return
ev = data.event
from_broadcaster = getattr(ev, "from_broadcaster_user_name", None) or getattr(ev, "from_broadcaster_user_login", "")
viewers = getattr(ev, "viewers", 0)
msg = _format_message(
cfg.message_twitch or "Bienvenue aux {viewers} viewers de {from_broadcaster_name} !",
from_broadcaster_name=from_broadcaster,
viewers=viewers,
)
if cfg.notify_twitch_chat and msg:
await _send_twitch(chat, channel, msg)
if cfg.notify_discord and cfg.discord_channel_id:
content = _format_message(
cfg.message_discord or "",
from_broadcaster_name=from_broadcaster,
viewers=viewers,
)
try:
embed_color = int(cfg.embed_color or "9146FF", 16)
except ValueError:
embed_color = 0x9146FF
embed = discord.Embed(
title=_format_message(
cfg.embed_title or "Raid reçu",
from_broadcaster_name=from_broadcaster,
viewers=viewers,
),
description=cfg.embed_description or f"{from_broadcaster} a raid avec {viewers} viewers !",
color=embed_color,
)
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
async def notify_clip(
chat: Chat | None,
channel: str,
*,
user: str,
title: str,
url: str,
thumbnail_url: str,
clip_id: str,
) -> None:
"""Appelé quand un nouveau clip est détecté (polling)."""
with webapp.app_context():
cfg = TwitchEventNotification.query.filter_by(event_type="clip", enable=True).first()
if not cfg:
return
msg_twitch = _format_message(
cfg.message_twitch or "Nouveau clip par {user} : {title} {url}",
user=user,
title=title,
url=url,
)
if cfg.notify_twitch_chat and chat and msg_twitch:
await _send_twitch(chat, channel, msg_twitch)
if cfg.notify_discord and cfg.discord_channel_id:
content = _format_message(
cfg.message_discord or "",
user=user,
title=title,
url=url,
thumbnail_url=thumbnail_url or "",
)
try:
embed_color = int(cfg.embed_color or "9146FF", 16)
except ValueError:
embed_color = 0x9146FF
embed = discord.Embed(
title=_format_message(cfg.embed_title or "Nouveau clip", user=user, title=title),
url=url,
description=cfg.embed_description or title,
color=embed_color,
)
if cfg.embed_thumbnail and thumbnail_url:
embed.set_thumbnail(url=thumbnail_url)
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
cfg.last_clip_id = clip_id
db.session.commit()
def create_eventsub(twitch: Twitch, callback_loop: asyncio.AbstractEventLoop) -> EventSubWebsocket:
"""Crée et démarre le client EventSub. Les callbacks seront exécutés sur `callback_loop`."""
eventsub = EventSubWebsocket(twitch, callback_loop=callback_loop)
eventsub.start()
return eventsub
async def register_eventsub_handlers(
eventsub: EventSubWebsocket,
broadcaster_id: str,
chat: Chat,
channel: str,
) -> None:
"""Enregistre follow, sub, raid sur l'EventSub. À appeler dans les 10 s après start()."""
# Définir les callbacks comme des wrappers explicites
async def on_follow(data: ChannelFollowEvent) -> None:
try:
await _handle_follow(data, chat, channel)
except Exception as e:
logger.error("Erreur handler follow: %s", e)
async def on_subscribe(data: ChannelSubscribeEvent) -> None:
try:
await _handle_subscribe(data, chat, channel)
except Exception as e:
logger.error("Erreur handler subscribe: %s", e)
async def on_raid(data: ChannelRaidEvent) -> None:
try:
await _handle_raid(data, chat, channel)
except Exception as e:
logger.error("Erreur handler raid: %s", e)
# Chaque souscription est tentée séparément : si le token n'a pas channel:read:subscriptions,
# seule "sub" échouera ; follow et raid restent actifs.
subscriptions_ok = 0
try:
await eventsub.listen_channel_follow_v2(broadcaster_id, broadcaster_id, on_follow)
logger.info("EventSub: follow enregistré ✓")
subscriptions_ok += 1
except Exception as e:
logger.error("EventSub follow: %s", e)
try:
await eventsub.listen_channel_subscribe(broadcaster_id, on_subscribe)
logger.info("EventSub: subscribe enregistré ✓")
subscriptions_ok += 1
except Exception as e:
logger.warning("EventSub subscribe (nécessite scope channel:read:subscriptions): %s", e)
try:
await eventsub.listen_channel_raid(to_broadcaster_user_id=broadcaster_id, callback=on_raid)
logger.info("EventSub: raid enregistré ✓")
subscriptions_ok += 1
except Exception as e:
logger.error("EventSub raid: %s", e)
if subscriptions_ok == 0:
logger.error("EventSub: AUCUNE souscription n'a réussi ! Le WebSocket va se fermer.")
else:
logger.info(f"EventSub: {subscriptions_ok}/3 souscriptions actives")
+166
View File
@@ -0,0 +1,166 @@
import re
import logging
from datetime import datetime, timedelta
from twitchAPI.twitch import Twitch
from twitchAPI.chat import ChatMessage
from database import db
from database.models import TwitchLinkFilter, TwitchAllowedDomain, TwitchPermit, TwitchAllowedUser
from twitchbot.moderation import _log_action, _get_broadcaster_id, _get_moderator_id, _get_user_id, _is_moderator
from webapp import webapp
logger = logging.getLogger('twitch-link-filter')
logger.setLevel(logging.INFO)
URL_REGEX = re.compile(r'https?://[^\s]+|(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,}(?:/[^\s]*)?')
def _get_filter_config():
"""Retourne un dictionnaire avec la config du filtre de liens"""
with webapp.app_context():
config = TwitchLinkFilter.query.first()
if not config:
config = TwitchLinkFilter(enabled=False)
db.session.add(config)
db.session.commit()
# Retourner un dict pour éviter DetachedInstanceError
return {
'enabled': config.enabled,
'allow_subscribers': config.allow_subscribers,
'allow_vips': config.allow_vips,
'allow_moderators': config.allow_moderators,
'timeout_duration': config.timeout_duration,
'warning_message': config.warning_message
}
def _get_allowed_domains():
with webapp.app_context():
return [d.domain.lower() for d in TwitchAllowedDomain.query.all()]
def _is_user_whitelisted(username: str) -> bool:
with webapp.app_context():
return TwitchAllowedUser.query.filter_by(username=username.lower()).first() is not None
def _has_valid_permit(username: str) -> bool:
with webapp.app_context():
permit = TwitchPermit.query.filter_by(username=username.lower()).first()
if permit and permit.expires_at > datetime.now():
return True
if permit and permit.expires_at <= datetime.now():
db.session.delete(permit)
db.session.commit()
return False
def _extract_domain(url: str) -> str:
url = url.lower()
url = re.sub(r'^https?://', '', url)
url = re.sub(r'^www\.', '', url)
return url.split('/')[0]
def _is_domain_allowed(url: str, allowed_domains: list) -> bool:
domain = _extract_domain(url)
for allowed in allowed_domains:
if domain == allowed or domain.endswith('.' + allowed):
return True
return False
async def check_message_for_links(msg: ChatMessage, twitch: Twitch) -> bool:
config = _get_filter_config()
if not config['enabled']:
return True
if config['allow_moderators'] and (msg.user.mod or msg.user.name.lower() == msg.room.name.lower()):
return True
if config['allow_vips'] and msg.user.vip:
return True
if config['allow_subscribers'] and msg.user.subscriber:
return True
if _is_user_whitelisted(msg.user.name):
return True
urls = URL_REGEX.findall(msg.text)
if not urls:
return True
if _has_valid_permit(msg.user.name):
with webapp.app_context():
permit = TwitchPermit.query.filter_by(username=msg.user.name.lower()).first()
if permit:
db.session.delete(permit)
db.session.commit()
return True
allowed_domains = _get_allowed_domains()
for url in urls:
if not _is_domain_allowed(url, allowed_domains):
await _handle_unauthorized_link(msg, twitch, config, url)
return False
return True
async def _handle_unauthorized_link(msg: ChatMessage, twitch: Twitch, config: dict, url: str):
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
user_id = await _get_user_id(twitch, msg.user.name)
if user_id and config['timeout_duration'] > 0:
try:
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason="Lien non autorise", duration=config['timeout_duration'])
except Exception as e:
logger.error(f"Erreur timeout link filter: {e}")
try:
await twitch.delete_chat_messages(broadcaster_id, moderator_id, message_id=msg.id)
except Exception as e:
logger.error(f"Erreur suppression message: {e}")
if config['warning_message']:
await msg.reply(config['warning_message'])
_log_action("link_blocked", "AutoMod", msg.user.name, _extract_domain(url))
logger.info(f"Lien bloque de {msg.user.name}: {url}")
async def permit_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !permit <viewer> [minutes]")
return
username = args[0].lstrip('@').lower()
duration = 60
if len(args) >= 2:
try:
duration = int(args[1]) * 60
except ValueError:
duration = 60
expires_at = datetime.now() + timedelta(seconds=duration)
with webapp.app_context():
existing = TwitchPermit.query.filter_by(username=username).first()
if existing:
existing.expires_at = expires_at
else:
permit = TwitchPermit(username=username, expires_at=expires_at)
db.session.add(permit)
db.session.commit()
_log_action("permit", msg.user.name, username, f"{duration}s")
await msg.reply(f"@{username} peut poster un lien pendant {duration // 60} minute(s)")
logger.info(f"Permit accorde a {username} par {msg.user.name}")
+155 -12
View File
@@ -1,4 +1,5 @@
import logging
import discord
from twitchAPI.twitch import Twitch
from twitchAPI.object.api import Stream
@@ -11,11 +12,76 @@ from webapp import webapp
logger = logging.getLogger('live-alert')
logger.setLevel(logging.INFO)
_live_alert_first_check = True
def _stream_thumbnail_url(stream: Stream) -> str:
"""URL de la miniature du stream (preview Twitch)."""
url = getattr(stream, 'thumbnail_url', None) or ''
if '{width}' in url or '{height}' in url:
url = url.replace('{width}', '320').replace('{height}', '180')
if not url:
url = f"https://static-cdn.jtvnw.net/previews-ttv/live_user_{stream.user_login}-320x180.jpg"
return url
def _format_embed_text(text: str, stream: Stream, stream_url: str, thumbnail: str) -> str:
"""Formate un texte d'embed avec les variables stream."""
if not text:
return ''
try:
return text.format(
user_login=stream.user_login or '',
user_name=stream.user_name or '',
game_name=getattr(stream, 'game_name', None) or '',
title=stream.title or '',
language=getattr(stream, 'language', None) or '',
stream_url=stream_url,
thumbnail=thumbnail,
)
except KeyError:
return text
async def checkOnlineStreamer(twitch: Twitch) :
global _live_alert_first_check
with webapp.app_context() :
alerts : list[LiveAlert] = LiveAlert.query.all()
streams = await _retreiveStreams(twitch, alerts)
watch_stream = None
# Récupération du statut du live principal (channel configuré)
from database.helpers import ConfigurationHelper
main_channel = ConfigurationHelper().getValue('twitch_channel')
main_stream = None
if main_channel:
main_stream = next((s for s in streams if s.user_login.lower() == main_channel.lower()), None)
# Mise à jour du BOT_STATUS pour la webapp
if main_stream:
webapp.config["BOT_STATUS"]["twitch_is_live"] = True
webapp.config["BOT_STATUS"]["twitch_viewer_count"] = getattr(main_stream, 'viewer_count', 0)
else:
webapp.config["BOT_STATUS"]["twitch_is_live"] = False
webapp.config["BOT_STATUS"]["twitch_viewer_count"] = 0
# Premier check : synchronisation sans notification
if _live_alert_first_check:
logger.info('Live Alert: première vérification, synchronisation sans notification')
for alert in alerts:
stream = next((s for s in streams if s.user_login == alert.login), None)
if stream:
alert.online = True
if alert.watch_activity and alert.enable:
watch_stream = stream
else:
alert.online = False
await _updateBotActivity(watch_stream)
db.session.commit()
_live_alert_first_check = False
return
# Vérifications normales ensuite
for alert in alerts :
stream = next((s for s in streams if s.user_login == alert.login), None)
if stream :
@@ -24,26 +90,103 @@ async def checkOnlineStreamer(twitch: Twitch) :
logger.info(f'N\'etait pas en ligne auparavant : {alert.login}')
await _notifyAlert(alert, stream)
alert.online = True
if alert.watch_activity and alert.enable:
watch_stream = stream
else :
logger.info(f'Streamer hors ligne : {alert.login}')
alert.online = False
await _updateBotActivity(watch_stream)
db.session.commit()
async def _notifyAlert(alert : LiveAlert, stream : Stream):
message : str = alert.message.format(stream)
logger.info(f'Message de notification : {message}')
bot.loop.create_task(_sendMessage(alert.notify_channel, message))
async def _sendMessage(channel : int, message : str) :
logger.info(f'Envoi de notification : {message}')
await bot.get_channel(channel).send(message)
logger.info(f'Notification envoyé')
async def _updateBotActivity(stream: Stream | None):
if stream:
logger.info(f'Mise à jour de l\'activité : Regarde le live de {stream.user_name}')
activity = discord.Streaming(
name=f'Regarde le live de {stream.user_name}',
url=f'https://www.twitch.tv/{stream.user_login}'
)
await bot.change_presence(status=discord.Status.online, activity=activity)
else:
logger.info('Aucun stream à regarder, retour à l\'activité normale')
# Remettre une humeur aléatoire
from database.models import Humeur
import random
humeurs = Humeur.query.all()
if humeurs:
humeur = random.choice(humeurs)
logger.info(f'Réinitialisation du statut : {humeur.text}')
await bot.change_presence(status=discord.Status.online, activity=discord.CustomActivity(humeur.text))
else:
# Si pas de humeur, remettre un statut par défaut
await bot.change_presence(status=discord.Status.online, activity=None)
async def _retreiveStreams(twitch: Twitch, alerts : list[LiveAlert]) -> list[Stream] :
streams : list[Stream] = []
async def _notifyAlert(alert: LiveAlert, stream: Stream):
stream_url = f'https://www.twitch.tv/{stream.user_login}'
thumbnail = _stream_thumbnail_url(stream)
# Message texte optionnel (avant l'embed)
message_text = None
if alert.message and alert.message.strip():
try:
message_text = alert.message.format(stream)
except KeyError:
message_text = alert.message
# Construction de l'embed Discord
try:
embed_color = int(alert.embed_color or '9146FF', 16)
except ValueError:
embed_color = 0x9146FF
embed_title = _format_embed_text(alert.embed_title, stream, stream_url, thumbnail) if alert.embed_title else (stream.title or f"{stream.user_name} est en live")
embed_description = _format_embed_text(alert.embed_description, stream, stream_url, thumbnail) if alert.embed_description else None
embed = discord.Embed(
title=embed_title,
url=stream_url,
color=embed_color
)
if embed_description:
embed.description = embed_description
author_name = _format_embed_text(alert.embed_author_name, stream, stream_url, thumbnail) if alert.embed_author_name else stream.user_name
user_id = getattr(stream, 'user_id', None)
author_icon = alert.embed_author_icon or (f"https://static-cdn.jtvnw.net/jtv_user_pictures/{user_id}-profile_image-70x70.png" if user_id else "https://static-cdn.jtvnw.net/ttv-favicon/favicon-32x32.png")
embed.set_author(name=author_name, icon_url=author_icon)
if alert.embed_thumbnail and thumbnail:
embed.set_thumbnail(url=thumbnail)
if alert.embed_image and thumbnail:
embed.set_image(url=thumbnail)
if alert.embed_footer:
footer_text = _format_embed_text(alert.embed_footer, stream, stream_url, thumbnail)
if footer_text:
embed.set_footer(text=footer_text)
logger.info(f'Envoi de notification live (embed) : {stream.user_login}')
bot.loop.create_task(_sendMessage(alert.notify_channel, message_text, embed))
async def _sendMessage(channel_id: int, message: str | None, embed: discord.Embed):
try:
discord_channel = bot.get_channel(channel_id)
if not discord_channel:
logger.error(f"Canal Discord {channel_id} introuvable")
return
if message and message.strip():
await discord_channel.send(content=message, embed=embed)
else:
await discord_channel.send(embed=embed)
logger.info('Notification live envoyée')
except Exception as e:
logger.error(f"Erreur lors de l'envoi de la notification live : {e}")
async def _retreiveStreams(twitch: Twitch, alerts: list[LiveAlert]) -> list[Stream]:
streams: list[Stream] = []
logger.info(f'Recherche de streams pour : {alerts}')
async for stream in twitch.get_streams(user_login = [alert.login for alert in alerts]):
async for stream in twitch.get_streams(user_login=[alert.login for alert in alerts]):
streams.append(stream)
logger.info(f'Ces streams sont en ligne : {streams}')
return streams
+446
View File
@@ -0,0 +1,446 @@
import re
import logging
from datetime import datetime
from twitchAPI.twitch import Twitch
from twitchAPI.chat import ChatMessage
from database import db
from database.models import TwitchAnnouncement, TwitchModerationLog, TwitchBannedWord
from webapp import webapp
logger = logging.getLogger('twitch-moderation')
logger.setLevel(logging.INFO)
last_multitwitch: str = None
games_disabled: bool = False
def _log_action(action: str, moderator: str, target: str = None, details: str = None):
with webapp.app_context():
log = TwitchModerationLog(
action=action,
moderator=moderator,
target=target,
details=details,
created_at=datetime.now()
)
db.session.add(log)
db.session.commit()
def _is_moderator(msg: ChatMessage) -> bool:
return msg.user.mod or msg.user.name.lower() == msg.room.name.lower()
async def _get_broadcaster_id(twitch: Twitch, channel: str) -> str:
async for user in twitch.get_users(logins=[channel]):
return user.id
return None
async def _get_user_id(twitch: Twitch, username: str) -> str:
async for user in twitch.get_users(logins=[username]):
return user.id
return None
async def _get_moderator_id(twitch: Twitch) -> str:
async for user in twitch.get_users():
return user.id
return None
async def timeout_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !timeout <viewer> [minutes] [raison]")
return
viewer = args[0].lstrip('@')
duration = 180 # 3 minutes par défaut
reason = "Timeout"
# Si args[1] est un nombre, c'est la durée, sinon c'est la raison
if len(args) >= 2:
try:
duration = int(args[1]) * 60
# Tout ce qui suit est la raison
if len(args) >= 3:
reason = ' '.join(args[2:])
except ValueError:
# args[1] n'est pas un nombre, donc tout depuis args[1] est la raison
reason = ' '.join(args[1:])
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason, duration=duration)
# Log avec durée et raison
log_details = f"{duration}s - {reason}"
_log_action("timeout", msg.user.name, viewer, log_details)
logger.info(f'{viewer} timeout pour {duration}s par {msg.user.name} - Raison: {reason}')
async def ban_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !ban <viewer1> [viewer2] ...")
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
for viewer in args[:5]:
viewer = viewer.lstrip('@')
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason="Ban")
_log_action("ban", msg.user.name, viewer)
logger.info(f'{viewer} banni par {msg.user.name}')
async def unban_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !unban <viewer1> [viewer2] ...")
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
for viewer in args[:5]:
viewer = viewer.lstrip('@')
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.unban_user(broadcaster_id, moderator_id, user_id)
_log_action("unban", msg.user.name, viewer)
logger.info(f'{viewer} débanni par {msg.user.name}')
async def clean_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
if len(args) >= 1:
viewer = args[0].lstrip('@')
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.delete_chat_messages(broadcaster_id, moderator_id, user_id=user_id)
_log_action("clean", msg.user.name, viewer)
logger.info(f'Messages de {viewer} supprimés par {msg.user.name}')
else:
await twitch.delete_chat_messages(broadcaster_id, moderator_id)
_log_action("clean", msg.user.name, None, "Chat complet")
logger.info(f'Chat nettoyé par {msg.user.name}')
async def shieldmode_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !shieldmode <on/off>")
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
is_active = args[0].lower() == "on"
await twitch.update_shield_mode_status(broadcaster_id, moderator_id, is_active)
_log_action("shieldmode", msg.user.name, None, "on" if is_active else "off")
logger.info(f'Shield mode {"activé" if is_active else "désactivé"} par {msg.user.name}')
async def settitle_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
parts = msg.text.split(maxsplit=1)
if len(parts) < 2:
await msg.reply("Usage: !settitle <titre>")
return
title = parts[1]
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
await twitch.modify_channel_information(broadcaster_id, title=title)
_log_action("settitle", msg.user.name, None, title)
logger.info(f'Titre changé en "{title}" par {msg.user.name}')
async def setgame_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
parts = msg.text.split(maxsplit=1)
if len(parts) < 2:
await msg.reply("Usage: !setgame <jeu>")
return
game_name = parts[1]
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
game_id = None
async for game in twitch.get_games(names=[game_name]):
game_id = game.id
break
if game_id:
await twitch.modify_channel_information(broadcaster_id, game_id=game_id)
_log_action("setgame", msg.user.name, None, game_name)
logger.info(f'Jeu changé en "{game_name}" par {msg.user.name}')
else:
await msg.reply(f"Jeu '{game_name}' introuvable")
async def subon_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=True)
_log_action("subon", msg.user.name)
logger.info(f'Mode abonnés activé par {msg.user.name}')
async def suboff_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=False)
_log_action("suboff", msg.user.name)
logger.info(f'Mode abonnés désactivé par {msg.user.name}')
async def follon_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
duration = 10
if len(args) >= 1:
try:
duration = int(args[0])
except ValueError:
duration = 10
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, follower_mode=True, follower_mode_duration=duration)
_log_action("follon", msg.user.name, None, f"{duration}min")
logger.info(f'Mode followers ({duration}min) activé par {msg.user.name}')
async def folloff_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, follower_mode=False)
_log_action("folloff", msg.user.name)
logger.info(f'Mode followers désactivé par {msg.user.name}')
async def emoteon_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=True)
_log_action("emoteon", msg.user.name)
logger.info(f'Mode emote activé par {msg.user.name}')
async def emoteoff_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=False)
_log_action("emoteoff", msg.user.name)
logger.info(f'Mode emote désactivé par {msg.user.name}')
async def multitwitch_command(msg: ChatMessage, twitch: Twitch):
global last_multitwitch
args = msg.text.split()[1:]
if len(args) == 0:
if last_multitwitch:
await msg.reply(last_multitwitch)
return
if not _is_moderator(msg):
return
if args[0].lower() == "reset":
last_multitwitch = None
logger.info(f'MultiTwitch reset par {msg.user.name}')
return
if args[0].lower() == "auto":
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
async for channel in twitch.get_channel_information(broadcaster_id):
title = channel.title
mentions = re.findall(r'@(\w+)', title)
if mentions:
channels = [msg.room.name] + mentions
last_multitwitch = f"https://multitwitch.live/{'/'.join(channels)}"
await msg.reply(last_multitwitch)
return
return
channels = []
for arg in args:
if arg == "@":
channels.append(msg.room.name)
else:
channels.append(arg.lstrip('@'))
last_multitwitch = f"https://multitwitch.live/{'/'.join(channels)}"
await msg.reply(last_multitwitch)
logger.info(f'MultiTwitch créé par {msg.user.name}: {last_multitwitch}')
async def ann_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 2:
await msg.reply("Usage: !ann <alias> <on/off/toggle>")
return
alias = args[0]
action = args[1].lower()
with webapp.app_context():
announcement = TwitchAnnouncement.query.filter_by(name=alias).first()
if not announcement:
await msg.reply(f"Annonce '{alias}' introuvable")
return
if action == "on":
announcement.enable = True
elif action == "off":
announcement.enable = False
elif action == "toggle":
announcement.enable = not announcement.enable
else:
await msg.reply("Action invalide: on/off/toggle")
return
db.session.commit()
status = "activée" if announcement.enable else "désactivée"
logger.info(f'Annonce {alias} {status} par {msg.user.name}')
await msg.reply(f"Annonce '{alias}' {status}")
async def no_game_command(msg: ChatMessage, twitch: Twitch):
global games_disabled
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !no_game <on/off>")
return
action = args[0].lower()
if action == "on":
games_disabled = True
logger.info(f'Jeux désactivés par {msg.user.name}')
await msg.reply("Jeux désactivés")
elif action == "off":
games_disabled = False
logger.info(f'Jeux activés par {msg.user.name}')
await msg.reply("Jeux activés")
def are_games_disabled() -> bool:
return games_disabled
async def check_message_for_banned_words(msg: ChatMessage, twitch: Twitch) -> bool:
"""
Vérifie si le message contient des mots interdits.
Retourne True si le message est valide, False s'il doit être bloqué.
"""
# Modérateurs et broadcaster exemptés
if msg.user.mod or msg.user.name.lower() == msg.room.name.lower():
return True
with webapp.app_context():
banned_words = TwitchBannedWord.query.filter_by(enabled=True).all()
if not banned_words:
return True
message_lower = msg.text.lower()
for banned_word_entry in banned_words:
word = banned_word_entry.word.lower()
# Recherche du mot dans le message (mot entier ou partie de mot)
if word in message_lower:
# Bloquer le message
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
user_id = await _get_user_id(twitch, msg.user.name)
# Timeout de l'utilisateur
if user_id and banned_word_entry.timeout_duration > 0:
try:
await twitch.ban_user(
broadcaster_id,
moderator_id,
user_id,
reason=f"Mot interdit: {banned_word_entry.word}",
duration=banned_word_entry.timeout_duration
)
except Exception as e:
logger.error(f"Erreur timeout mot interdit: {e}")
# Suppression du message
try:
await twitch.delete_chat_messages(broadcaster_id, moderator_id, message_id=msg.id)
except Exception as e:
logger.error(f"Erreur suppression message mot interdit: {e}")
# Log
_log_action("banned_word", "AutoMod", msg.user.name, f"Mot: {banned_word_entry.word}")
logger.info(f"Mot interdit détecté de {msg.user.name}: {banned_word_entry.word}")
return False
return True
+53 -3
View File
@@ -1,7 +1,57 @@
from flask import Flask
import os
from flask import Flask
from flask_login import LoginManager
webapp = Flask(__name__)
webapp.secret_key = os.environ.get('FLASK_SECRET_KEY', 'mamie-henriette-secret-key-change-me')
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, freegames, youtube_alert
# Secret key pour les sessions (Flask-Login)
webapp.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-production")
# É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,
"twitch_is_live": False,
"twitch_viewer_count": 0,
"twitch_chat_messages": [], # Derniers messages du chat (max 100)
}
login_manager = LoginManager()
login_manager.init_app(webapp)
login_manager.login_view = "login"
login_manager.login_message = "Veuillez vous connecter pour accéder à cette page."
from database.models import WebappUser
@login_manager.user_loader
def load_user(user_id):
try:
return WebappUser.query.get(int(user_id))
except (ValueError, TypeError):
return None
from webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot
from flask import request, redirect, url_for
from flask_login import current_user
@webapp.context_processor
def inject_user_level():
from flask_login import current_user
from database.helpers import ConfigurationHelper
reg = ConfigurationHelper().getValue("registration_enabled")
registration_enabled = reg not in (None, "", "false", "0", "no", "off")
return {
"current_user_level": current_user.get_level() if current_user.is_authenticated else -1,
"registration_enabled": registration_enabled,
}
@webapp.before_request
def require_login():
"""Redirige vers /login si non authentifié (sauf login, register, static, callback Twitch OAuth)."""
if request.endpoint in (None, "login", "register", "static", "twitchReceiveToken"):
return
if not current_user.is_authenticated:
return redirect(url_for("login", next=request.url))
+84
View File
@@ -0,0 +1,84 @@
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import TwitchAnnouncement
@webapp.route("/announcements")
@require_page("announcements")
def openAnnouncements():
announcements = TwitchAnnouncement.query.all()
return render_template("announcements.html", announcements=announcements)
@webapp.route("/announcements/add", methods=['POST'])
@require_page("announcements")
def addAnnouncement():
if not can_write_page("announcements"):
return render_template("403.html"), 403
announcement = TwitchAnnouncement(
enable=True,
name=request.form.get('name'),
text=request.form.get('text'),
periodicity=int(request.form.get('periodicity', 10)),
min_chat_messages=int(request.form.get('min_chat_messages', 0))
)
db.session.add(announcement)
db.session.commit()
return redirect(url_for("openAnnouncements"))
@webapp.route("/announcements/toggle/<int:id>")
@require_page("announcements")
def toggleAnnouncement(id):
if not can_write_page("announcements"):
return render_template("403.html"), 403
announcement = TwitchAnnouncement.query.get_or_404(id)
announcement.enable = not announcement.enable
db.session.commit()
return redirect(url_for("openAnnouncements"))
@webapp.route("/announcements/edit/<int:id>")
@require_page("announcements")
def openEditAnnouncement(id):
announcement = TwitchAnnouncement.query.get_or_404(id)
return render_template("announcements.html", announcement=announcement)
@webapp.route("/announcements/edit/<int:id>", methods=['POST'])
@require_page("announcements")
def submitEditAnnouncement(id):
if not can_write_page("announcements"):
return render_template("403.html"), 403
announcement = TwitchAnnouncement.query.get_or_404(id)
announcement.name = request.form.get('name')
announcement.text = request.form.get('text')
announcement.periodicity = int(request.form.get('periodicity', 10))
announcement.min_chat_messages = int(request.form.get('min_chat_messages', 0))
db.session.commit()
return redirect(url_for("openAnnouncements"))
@webapp.route("/announcements/del/<int:id>")
@require_page("announcements")
def delAnnouncement(id):
if not can_write_page("announcements"):
return render_template("403.html"), 403
announcement = TwitchAnnouncement.query.get_or_404(id)
db.session.delete(announcement)
db.session.commit()
return redirect(url_for("openAnnouncements"))
@webapp.route("/announcements/reset/<int:id>")
@require_page("announcements")
def resetAnnouncement(id):
if not can_write_page("announcements"):
return render_template("403.html"), 403
announcement = TwitchAnnouncement.query.get_or_404(id)
announcement.last_sent = None
db.session.commit()
return redirect(url_for("openAnnouncements"))
+164
View File
@@ -0,0 +1,164 @@
# Authentification webapp : login, register, logout et contrôle d'accès par rôles.
from functools import wraps
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from database import db
from database.models import WebappUser, ROLE_ORDER, PagePermission
from database.helpers import ConfigurationHelper
from webapp import webapp
def require_roles(allowed_roles: list):
"""Décorateur : exige que l'utilisateur soit authentifié et ait l'un des rôles autorisés."""
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for("login", next=request.url))
if current_user.role not in allowed_roles:
return render_template("403.html"), 403
return f(*args, **kwargs)
return wrapped
return decorator
def require_role_min(min_role: str):
"""Décorateur : exige que l'utilisateur ait au moins le rôle min_role (niveau en base)."""
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for("login", next=request.url))
if not current_user.has_role_at_least(min_role):
return render_template("403.html"), 403
return f(*args, **kwargs)
return wrapped
return decorator
def _page_min_level(page_key: str, for_write: bool = False) -> int:
"""Niveau minimum requis pour la page (lecture ou écriture)."""
perm = PagePermission.query.filter_by(page_key=page_key).first()
if not perm:
return 0
if for_write and perm.write_level is not None:
return perm.write_level
return perm.min_level
def require_page(page_key: str):
"""Décorateur : accès en lecture selon les permissions de la page (webapp_page_permission)."""
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for("login", next=request.url))
min_level = _page_min_level(page_key, for_write=False)
if not current_user.has_level_at_least(min_level):
return render_template("403.html"), 403
return f(*args, **kwargs)
return wrapped
return decorator
def require_page_write(page_key: str):
"""Décorateur : accès en écriture selon les permissions de la page."""
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for("login", next=request.url))
min_level = _page_min_level(page_key, for_write=True)
if not current_user.has_level_at_least(min_level):
return render_template("403.html"), 403
return f(*args, **kwargs)
return wrapped
return decorator
def can_write_page(page_key: str) -> bool:
"""Retourne True si l'utilisateur connecté a le niveau pour écrire sur cette page."""
if not current_user.is_authenticated:
return False
return current_user.has_level_at_least(_page_min_level(page_key, for_write=True))
@webapp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("index"))
if request.method == "POST":
identifier = (request.form.get("identifier") or "").strip()
password = request.form.get("password") or ""
if not identifier or not password:
flash("Identifiant et mot de passe requis.", "error")
return render_template("login.html")
user = WebappUser.query.filter(
(WebappUser.username == identifier) | (WebappUser.email == identifier)
).first()
if user and check_password_hash(user.password_hash, password):
login_user(user, remember=True)
next_url = request.args.get("next")
if next_url and next_url.startswith("/"):
return redirect(next_url)
return redirect(url_for("index"))
flash("Identifiant ou mot de passe incorrect.", "error")
return render_template("login.html")
return render_template("login.html")
@webapp.route("/register", methods=["GET", "POST"])
def register():
if current_user.is_authenticated:
return redirect(url_for("index"))
# Inscriptions désactivées par le super admin
reg_enabled = ConfigurationHelper().getValue("registration_enabled")
if reg_enabled in (None, "", "false", "0", "no", "off"):
flash("Les inscriptions sont désactivées.", "error")
return redirect(url_for("login"))
if request.method == "POST":
username = (request.form.get("username") or "").strip()
email = (request.form.get("email") or "").strip().lower()
password = request.form.get("password") or ""
password_confirm = request.form.get("password_confirm") or ""
errors = []
if len(username) < 3:
errors.append("Le nom d'utilisateur doit faire au moins 3 caractères.")
if len(email) < 5 or "@" not in email:
errors.append("Adresse e-mail invalide.")
if len(password) < 8:
errors.append("Le mot de passe doit faire au moins 8 caractères.")
if password != password_confirm:
errors.append("Les mots de passe ne correspondent pas.")
if WebappUser.query.filter_by(username=username).first():
errors.append("Ce nom d'utilisateur est déjà pris.")
if WebappUser.query.filter_by(email=email).first():
errors.append("Cette adresse e-mail est déjà utilisée.")
if errors:
for msg in errors:
flash(msg, "error")
return render_template("register.html")
# Premier inscrit = super administrateur
role = "super_administrateur" if WebappUser.query.count() == 0 else "viewer_twitch"
user = WebappUser(
username=username,
email=email,
password_hash=generate_password_hash(password, method="scrypt"),
role=role,
)
db.session.add(user)
db.session.commit()
flash("Compte créé. Vous pouvez vous connecter.", "success")
return redirect(url_for("login"))
return render_template("register.html")
@webapp.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("login"))
+22 -2
View File
@@ -1,19 +1,30 @@
from flask import render_template, request, redirect, url_for, flash
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import Commande
@webapp.route("/commandes")
@require_page("commandes")
def commandes():
commandes_list = Commande.query.all()
return render_template("commandes.html", commandes=commandes_list)
return render_template("commandes.html", commandes=commandes_list, twitch_permissions=TWITCH_PERMISSIONS)
TWITCH_PERMISSIONS = {'viewer': 'Tous (viewers)', 'sub': 'Abonnés', 'vip': 'VIP', 'moderator': 'Modérateur'}
@webapp.route("/commandes/add", methods=['POST'])
@require_page("commandes")
def add_commande():
if not can_write_page("commandes"):
return render_template("403.html"), 403
trigger = request.form.get('trigger')
response = request.form.get('response')
discord_enable = request.form.get('discord_enable') != None
twitch_enable = request.form.get('twitch_enable') != None
twitch_permission = request.form.get('twitch_permission') or 'viewer'
if twitch_permission not in TWITCH_PERMISSIONS:
twitch_permission = 'viewer'
if trigger and response:
if not trigger.startswith('!'):
@@ -21,28 +32,37 @@ def add_commande():
existing = Commande.query.filter_by(trigger=trigger).first()
if not existing:
commande = Commande(trigger=trigger, response=response, discord_enable=discord_enable, twitch_enable=twitch_enable)
commande = Commande(trigger=trigger, response=response, discord_enable=discord_enable, twitch_enable=twitch_enable, twitch_permission=twitch_permission)
db.session.add(commande)
db.session.commit()
return redirect(url_for('commandes'))
@webapp.route("/commandes/delete/<int:commande_id>")
@require_page("commandes")
def delete_commande(commande_id):
if not can_write_page("commandes"):
return render_template("403.html"), 403
commande = Commande.query.get_or_404(commande_id)
db.session.delete(commande)
db.session.commit()
return redirect(url_for('commandes'))
@webapp.route("/commandes/toggle-discord/<int:commande_id>")
@require_page("commandes")
def toggle_discord_commande(commande_id):
if not can_write_page("commandes"):
return render_template("403.html"), 403
commande = Commande.query.get_or_404(commande_id)
commande.discord_enable = not commande.discord_enable
db.session.commit()
return redirect(url_for('commandes'))
@webapp.route("/commandes/toggle-twitch/<int:commande_id>")
@require_page("commandes")
def toggle_twitch_commande(commande_id):
if not can_write_page("commandes"):
return render_template("403.html"), 403
commande = Commande.query.get_or_404(commande_id)
commande.twitch_enable = not commande.twitch_enable
db.session.commit()
+8 -29
View File
@@ -1,15 +1,17 @@
from flask import render_template, request, redirect, url_for, flash
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page
from database import db
from database.helpers import ConfigurationHelper
from discordbot import bot
import asyncio
@webapp.route("/configurations")
@require_page("configurations")
def openConfigurations():
return render_template("configurations.html", configuration = ConfigurationHelper(), channels = bot.getAllTextChannel(), roles = bot.getAllRoles(), guilds = bot.getAllGuilds())
return render_template("configurations.html", configuration=ConfigurationHelper(), channels=bot.getAllTextChannel(), voice_channels=bot.getAllVoiceChannels(), roles=bot.getAllRoles())
@webapp.route("/configurations/update", methods=['POST'])
@webapp.route("/configurations/update", methods=['POST'])
@require_page("configurations")
def updateConfiguration():
checkboxes = {
'humble_bundle_enable': 'humble_bundle_channel',
@@ -19,7 +21,8 @@ def updateConfiguration():
'moderation_kick_enable': 'moderation_staff_role_ids',
'welcome_enable': 'welcome_channel_id',
'leave_enable': 'leave_channel_id',
'autorole_enable': 'autorole_role_id'
'auto_rooms_enable': 'auto_rooms_channel_id',
'twitch_commands_enable': 'twitch_channel'
}
staff_roles = request.form.getlist('moderation_staff_role_ids')
@@ -42,27 +45,3 @@ def updateConfiguration():
db.session.commit()
return redirect(request.referrer)
@webapp.route("/configurations/leave-guild", methods=['POST'])
def leaveGuild():
guild_id = request.form.get('guild_id')
confirm = request.form.get('confirm_leave')
if not guild_id or not confirm:
flash('Veuillez sélectionner un serveur et confirmer l\'action.', 'error')
return redirect(url_for('openConfigurations'))
try:
guild_id_int = int(guild_id)
# Exécuter la coroutine dans le loop du bot
future = asyncio.run_coroutine_threadsafe(bot.leaveGuild(guild_id_int), bot.loop)
result = future.result(timeout=10)
if result:
flash('Le bot a quitté le serveur avec succès.', 'success')
else:
flash('Serveur non trouvé.', 'error')
except Exception as e:
flash(f'Erreur lors de la tentative de quitter le serveur : {str(e)}', 'error')
return redirect(url_for('openConfigurations'))
-133
View File
@@ -1,133 +0,0 @@
from flask import render_template, request, redirect, url_for, jsonify
from webapp import webapp
from database import db
from database.helpers import ConfigurationHelper
from database.models import FreeGame
from discordbot import bot
from discordbot.freegames import (
fetchAndStoreGames,
getPendingGames,
getAllGames,
markAsNotified,
resetNotification,
KNOWN_SOURCES
)
import asyncio
@webapp.route("/freegames")
def openFreeGames():
"""Page principale de gestion des jeux gratuits"""
# Rafraîchir les jeux depuis le flux RSS
fetchAndStoreGames()
games = getAllGames()
pending_games = getPendingGames()
return render_template(
"freegames.html",
configuration=ConfigurationHelper(),
channels=bot.getAllTextChannel(),
roles=bot.getAllRoles(),
games=games,
pending_count=len(pending_games),
sources=KNOWN_SOURCES
)
@webapp.route("/freegames/config", methods=['POST'])
def updateFreeGamesConfig():
"""Met à jour la configuration du module Free Games"""
helper = ConfigurationHelper()
# Gestion de la checkbox enable
if request.form.get('freegames_channel') is not None:
if request.form.get('freegames_enable') is None:
helper.createOrUpdate('freegames_enable', False)
else:
helper.createOrUpdate('freegames_enable', 'on')
# Gestion de la checkbox auto_notify
if request.form.get('freegames_auto_notify') is None:
helper.createOrUpdate('freegames_auto_notify', False)
else:
helper.createOrUpdate('freegames_auto_notify', 'on')
# Canal de notification
if request.form.get('freegames_channel'):
helper.createOrUpdate('freegames_channel', request.form.get('freegames_channel'))
# Type de mention
if request.form.get('freegames_mention_type'):
helper.createOrUpdate('freegames_mention_type', request.form.get('freegames_mention_type'))
# Rôle à mentionner
if request.form.get('freegames_mention_role'):
helper.createOrUpdate('freegames_mention_role', request.form.get('freegames_mention_role'))
# Sources à suivre
sources = request.form.getlist('freegames_sources')
helper.createOrUpdate('freegames_sources', ','.join(sources) if sources else '')
db.session.commit()
return redirect(url_for('openFreeGames'))
@webapp.route("/freegames/notify/<int:game_id>", methods=['POST'])
def notifyFreeGame(game_id):
"""Envoie une notification pour un jeu spécifique"""
from discordbot.freegames import notifyGame
try:
# Utiliser le loop du bot Discord pour exécuter la coroutine
if bot.loop and bot.loop.is_running():
future = asyncio.run_coroutine_threadsafe(notifyGame(bot, game_id), bot.loop)
result = future.result(timeout=30) # Attendre max 30 secondes
else:
return jsonify({'success': False, 'message': 'Le bot Discord n\'est pas connecté'}), 400
if result:
return jsonify({'success': True, 'message': 'Notification envoyée'})
else:
return jsonify({'success': False, 'message': 'Échec de l\'envoi'}), 400
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
@webapp.route("/freegames/mark-notified/<int:game_id>", methods=['POST'])
def markGameNotified(game_id):
"""Marque un jeu comme notifié sans envoyer de message"""
if markAsNotified(game_id):
return jsonify({'success': True})
return jsonify({'success': False}), 400
@webapp.route("/freegames/reset/<int:game_id>", methods=['POST'])
def resetGameNotification(game_id):
"""Réinitialise le statut de notification d'un jeu"""
if resetNotification(game_id):
return jsonify({'success': True})
return jsonify({'success': False}), 400
@webapp.route("/freegames/refresh", methods=['POST'])
def refreshFreeGames():
"""Force le rafraîchissement du flux RSS"""
new_games = fetchAndStoreGames()
return jsonify({
'success': True,
'new_games': len(new_games),
'message': f'{len(new_games)} nouveau(x) jeu(x) trouvé(s)'
})
@webapp.route("/freegames/delete/<int:game_id>", methods=['POST'])
def deleteFreeGame(game_id):
"""Supprime un jeu de la liste"""
game = FreeGame.query.get(game_id)
if game:
db.session.delete(game)
db.session.commit()
return jsonify({'success': True})
return jsonify({'success': False}), 404
+110
View File
@@ -0,0 +1,110 @@
# Page webapp : configuration des notifications FreeLoot (feed LootScraper)
from flask import render_template, request, redirect, url_for
from urllib.parse import urlencode
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.helpers import ConfigurationHelper
from discordbot import bot
from discordbot.freeloot import send_entry_to_discord_sync
from freeloot_feed import SOURCES, get_display_entries
def _format_updated(updated: str | None) -> str:
"""Formate la date ISO en affichage court."""
if not updated:
return ""
try:
from datetime import datetime
dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
return dt.strftime("%d/%m/%Y %H:%M")
except Exception:
return updated[:16] if len(updated or "") >= 16 else (updated or "")
def _parse_mention_config(raw: str | None) -> tuple[bool, bool, list[str]]:
"""Retourne (everyone, here, list of role_ids) depuis freeloot_mention."""
everyone, here, role_ids = False, False, []
if not raw or not str(raw).strip():
return (everyone, here, role_ids)
for part in str(raw).strip().split(","):
part = part.strip()
if part == "everyone":
everyone = True
elif part == "here":
here = True
elif part.isdigit():
role_ids.append(part)
return (everyone, here, role_ids)
@webapp.route("/freeloot")
@require_page("freeloot")
def openFreeLoot():
helper = ConfigurationHelper()
channels = bot.getAllTextChannel()
roles = bot.getAllRoles()
raw_sources = helper.getValue("freeloot_sources")
enabled_sources = []
if raw_sources and str(raw_sources).strip():
enabled_sources = [s.strip() for s in str(raw_sources).split(",") if s.strip()]
raw_mention = helper.getValue("freeloot_mention")
mention_everyone, mention_here, mention_role_ids = _parse_mention_config(raw_mention)
entries = get_display_entries()
if enabled_sources:
entries = [e for e in entries if e.get("source_key") in enabled_sources]
for e in entries:
e["updated_formatted"] = _format_updated(e.get("updated"))
return render_template(
"freeloot.html",
configuration=helper,
channels=channels,
roles=roles,
sources=SOURCES,
enabled_sources=enabled_sources,
mention_everyone=mention_everyone,
mention_here=mention_here,
mention_role_ids=mention_role_ids,
entries=entries,
)
@webapp.route("/freeloot/update", methods=["POST"])
@require_page("freeloot")
def updateFreeLoot():
if not can_write_page("freeloot"):
return render_template("403.html"), 403
helper = ConfigurationHelper()
enable = request.form.get("freeloot_enable") in ("on", "1", "true", "yes")
channel_id = request.form.get("freeloot_channel_id")
source_keys = request.form.getlist("freeloot_sources")
mention_parts = []
if request.form.get("freeloot_mention_everyone"):
mention_parts.append("everyone")
if request.form.get("freeloot_mention_here"):
mention_parts.append("here")
mention_parts.extend(request.form.getlist("freeloot_mention_roles"))
helper.createOrUpdate("freeloot_enable", "true" if enable else "false")
if channel_id:
try:
helper.createOrUpdate("freeloot_channel_id", str(int(channel_id)))
except ValueError:
pass
helper.createOrUpdate("freeloot_sources", ",".join(source_keys) if source_keys else "")
helper.createOrUpdate("freeloot_mention", ",".join(mention_parts))
db.session.commit()
return redirect(url_for("openFreeLoot") + "?msg=Configuration enregistrée.&type=success")
@webapp.route("/freeloot/send", methods=["POST"])
@require_page("freeloot")
def send_free_loot_to_discord():
if not can_write_page("freeloot"):
return render_template("403.html"), 403
entry_id = (request.form.get("entry_id") or "").strip()
if not entry_id:
return redirect(url_for("openFreeLoot") + "?" + urlencode({"msg": "Entrée manquante.", "type": "error"}))
ok, message = send_entry_to_discord_sync(bot, entry_id)
msg_type = "success" if ok else "error"
return redirect(url_for("openFreeLoot") + "?" + urlencode({"msg": message, "type": msg_type}))
+9 -1
View File
@@ -1,22 +1,30 @@
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import Humeur
@webapp.route("/humeurs")
@require_page("humeurs")
def listHumeurs():
humeurs = Humeur.query.all()
return render_template("humeurs.html", humeurs = humeurs)
return render_template("humeurs.html", humeurs=humeurs)
@webapp.route('/humeurs/add', methods=['POST'])
@require_page("humeurs")
def addHumeur():
if not can_write_page("humeurs"):
return render_template("403.html"), 403
humeur = Humeur(text=request.form['text'])
db.session.add(humeur)
db.session.commit()
return redirect(url_for('listHumeurs'))
@webapp.route('/humeurs/del/<id>')
@require_page("humeurs")
def delHumeur(id):
if not can_write_page("humeurs"):
return render_template("403.html"), 403
Humeur.query.filter_by(id=id).delete()
db.session.commit()
return redirect(url_for('listHumeurs'))
+18 -40
View File
@@ -1,44 +1,22 @@
from flask import render_template, request, jsonify
from flask import render_template
from webapp import webapp
from shared_stats import stats_manager, discord_bridge
from webapp.auth import require_page
from database.models import ModerationEvent, TwitchAnnouncement, TwitchModerationLog
@webapp.route("/")
@require_page("index")
def index():
stats = stats_manager.get_stats()
channels = discord_bridge.get_text_channels()
return render_template("index.html", stats=stats, channels=channels)
@webapp.route("/api/channels")
def api_channels():
"""API pour récupérer la liste des canaux Discord"""
channels = discord_bridge.get_text_channels()
return jsonify(channels)
@webapp.route("/api/send-message", methods=["POST"])
def api_send_message():
"""API pour envoyer un message dans un canal Discord"""
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "Données manquantes"}), 400
channel_id = data.get("channel_id")
message = data.get("message")
if not channel_id:
return jsonify({"success": False, "error": "Canal non spécifié"}), 400
if not message or not message.strip():
return jsonify({"success": False, "error": "Message vide"}), 400
try:
channel_id = int(channel_id)
except ValueError:
return jsonify({"success": False, "error": "ID de canal invalide"}), 400
success, result_message = discord_bridge.send_message(channel_id, message)
if success:
return jsonify({"success": True, "message": result_message})
else:
return jsonify({"success": False, "error": result_message}), 400
status = webapp.config["BOT_STATUS"]
sanctions_count = ModerationEvent.query.count()
twitch_announcements_count = TwitchAnnouncement.query.count()
twitch_moderation_count = TwitchModerationLog.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"],
twitch_announcements_count=twitch_announcements_count,
twitch_moderation_count=twitch_moderation_count,
)
+103
View File
@@ -0,0 +1,103 @@
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import TwitchLinkFilter, TwitchAllowedDomain, TwitchAllowedUser
def _get_or_create_config():
config = TwitchLinkFilter.query.first()
if not config:
config = TwitchLinkFilter(enabled=False)
db.session.add(config)
db.session.commit()
return config
@webapp.route("/link-filter")
@require_page("link_filter")
def link_filter():
config = _get_or_create_config()
domains = TwitchAllowedDomain.query.order_by(TwitchAllowedDomain.domain).all()
users = TwitchAllowedUser.query.order_by(TwitchAllowedUser.username).all()
return render_template("link-filter.html", config=config, domains=domains, users=users)
@webapp.route("/link-filter/toggle")
@require_page("link_filter")
def toggle_link_filter():
if not can_write_page("link_filter"):
return render_template("403.html"), 403
config = _get_or_create_config()
config.enabled = not config.enabled
db.session.commit()
return redirect(url_for('link_filter'))
@webapp.route("/link-filter/update", methods=['POST'])
@require_page("link_filter")
def update_link_filter():
if not can_write_page("link_filter"):
return render_template("403.html"), 403
config = _get_or_create_config()
config.allow_subscribers = request.form.get('allow_subscribers') is not None
config.allow_vips = request.form.get('allow_vips') is not None
config.allow_moderators = request.form.get('allow_moderators') is not None
config.timeout_duration = int(request.form.get('timeout_duration', 60))
config.warning_message = request.form.get('warning_message', '')
db.session.commit()
return redirect(url_for('link_filter'))
@webapp.route("/link-filter/domain/add", methods=['POST'])
@require_page("link_filter")
def add_allowed_domain():
if not can_write_page("link_filter"):
return render_template("403.html"), 403
domain = request.form.get('domain', '').strip().lower()
if domain:
domain = domain.replace('https://', '').replace('http://', '').replace('www.', '')
domain = domain.split('/')[0]
existing = TwitchAllowedDomain.query.filter_by(domain=domain).first()
if not existing:
new_domain = TwitchAllowedDomain(domain=domain)
db.session.add(new_domain)
db.session.commit()
return redirect(url_for('link_filter'))
@webapp.route("/link-filter/domain/delete/<int:domain_id>")
@require_page("link_filter")
def delete_allowed_domain(domain_id):
if not can_write_page("link_filter"):
return render_template("403.html"), 403
domain = TwitchAllowedDomain.query.get_or_404(domain_id)
db.session.delete(domain)
db.session.commit()
return redirect(url_for('link_filter'))
@webapp.route("/link-filter/user/add", methods=['POST'])
@require_page("link_filter")
def add_allowed_user():
if not can_write_page("link_filter"):
return render_template("403.html"), 403
username = request.form.get('username', '').strip().lower().lstrip('@')
if username:
existing = TwitchAllowedUser.query.filter_by(username=username).first()
if not existing:
new_user = TwitchAllowedUser(username=username)
db.session.add(new_user)
db.session.commit()
return redirect(url_for('link_filter'))
@webapp.route("/link-filter/user/delete/<int:user_id>")
@require_page("link_filter")
def delete_allowed_user(user_id):
if not can_write_page("link_filter"):
return render_template("403.html"), 403
user = TwitchAllowedUser.query.get_or_404(user_id)
db.session.delete(user)
db.session.commit()
return redirect(url_for('link_filter'))
+57 -3
View File
@@ -1,12 +1,14 @@
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import LiveAlert
from discordbot import bot
@webapp.route("/live-alert")
@require_page("live_alert")
def openLiveAlert():
alerts : list[LiveAlert] = LiveAlert.query.all()
channels = bot.getAllTextChannel()
@@ -17,37 +19,89 @@ def openLiveAlert():
return render_template("live-alert.html", alerts = alerts, channels = channels)
@webapp.route("/live-alert/add", methods=['POST'])
@require_page("live_alert")
def addLiveAlert():
alert = LiveAlert(enable = True, login = request.form.get('login'), notify_channel = request.form.get('notify_channel'), message = request.form.get('message'))
if not can_write_page("live_alert"):
return render_template("403.html"), 403
embed_color = (request.form.get('embed_color') or '9146FF').strip().lstrip('#')
if len(embed_color) != 6:
embed_color = '9146FF'
alert = LiveAlert(
enable=True,
login=request.form.get('login'),
notify_channel=request.form.get('notify_channel'),
message=(request.form.get('message') or '').strip(),
watch_activity=request.form.get('watch_activity') == '1',
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(alert)
db.session.commit()
return redirect(url_for("openLiveAlert"))
@webapp.route("/live-alert/toggle/<int:id>")
@require_page("live_alert")
def toggleLiveAlert(id):
if not can_write_page("live_alert"):
return render_template("403.html"), 403
alert : LiveAlert = LiveAlert.query.get_or_404(id)
alert.enable = not alert.enable
db.session.commit()
return redirect(url_for("openLiveAlert"))
@webapp.route("/live-alert/edit/<int:id>")
@require_page("live_alert")
def openEditLiveAlert(id):
alert = LiveAlert.query.get_or_404(id)
channels = bot.getAllTextChannel()
return render_template("live-alert.html", alert = alert, channels = channels)
@webapp.route("/live-alert/edit/<int:id>", methods=['POST'])
@require_page("live_alert")
def submitEditLiveAlert(id):
alert : LiveAlert = LiveAlert.query.get_or_404(id)
if not can_write_page("live_alert"):
return render_template("403.html"), 403
alert: LiveAlert = LiveAlert.query.get_or_404(id)
embed_color = (request.form.get('embed_color') or '9146FF').strip().lstrip('#')
if len(embed_color) != 6:
embed_color = '9146FF'
alert.login = request.form.get('login')
alert.notify_channel = request.form.get('notify_channel')
alert.message = request.form.get('message')
alert.message = (request.form.get('message') or '').strip()
alert.watch_activity = request.form.get('watch_activity') == '1'
alert.embed_title = request.form.get('embed_title') or None
alert.embed_description = request.form.get('embed_description') or None
alert.embed_color = embed_color
alert.embed_footer = request.form.get('embed_footer') or None
alert.embed_author_name = request.form.get('embed_author_name') or None
alert.embed_author_icon = request.form.get('embed_author_icon') or None
alert.embed_thumbnail = request.form.get('embed_thumbnail') == 'on'
alert.embed_image = request.form.get('embed_image') == 'on'
db.session.commit()
return redirect(url_for("openLiveAlert"))
@webapp.route("/live-alert/toggle-watch/<int:id>")
@require_page("live_alert")
def toggleWatchActivity(id):
if not can_write_page("live_alert"):
return render_template("403.html"), 403
alert : LiveAlert = LiveAlert.query.get_or_404(id)
alert.watch_activity = not alert.watch_activity
db.session.commit()
return redirect(url_for("openLiveAlert"))
@webapp.route("/live-alert/del/<int:id>")
@require_page("live_alert")
def delLiveAlert(id):
if not can_write_page("live_alert"):
return render_template("403.html"), 403
alert = LiveAlert.query.get_or_404(id)
db.session.delete(alert)
db.session.commit()
+54 -178
View File
@@ -1,205 +1,81 @@
from flask import render_template, request, redirect, url_for, flash, jsonify
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import ModerationEvent, DiscordInvite
from datetime import datetime, timedelta, timezone
from collections import Counter
from shared_stats import discord_bridge
from database.models import ModerationEvent
def get_moderation_stats():
"""Calcule les statistiques de modération"""
events = ModerationEvent.query.all()
if not events:
return {
'total': 0,
'top_moderators': [],
'type_counts': {},
'recent_24h': 0,
'recent_7d': 0,
'recent_30d': 0,
'top_sanctioned': []
}
now = datetime.now()
# Comptages par modérateur
mod_counts = Counter(e.staff_name for e in events if e.staff_name)
top_moderators = mod_counts.most_common(5)
# Comptages par type
type_counts = Counter(e.type for e in events if e.type)
# Événements récents
recent_24h = sum(1 for e in events if e.created_at and (now - e.created_at) < timedelta(hours=24))
recent_7d = sum(1 for e in events if e.created_at and (now - e.created_at) < timedelta(days=7))
recent_30d = sum(1 for e in events if e.created_at and (now - e.created_at) < timedelta(days=30))
# Top des utilisateurs les plus sanctionnés
user_counts = Counter(e.username for e in events if e.username)
top_sanctioned = user_counts.most_common(5)
return {
'total': len(events),
'top_moderators': top_moderators,
'type_counts': dict(type_counts),
'recent_24h': recent_24h,
'recent_7d': recent_7d,
'recent_30d': recent_30d,
'top_sanctioned': top_sanctioned
}
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")
@require_page("moderation")
def moderation():
events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
stats = get_moderation_stats()
return render_template("moderation.html", events=events, event=None, stats=stats,
invites=[], invite_stats=None, guilds=[], show_invites=False, show_revoked=False)
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>")
@require_page("moderation")
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()
stats = get_moderation_stats()
return render_template("moderation.html", events=events, event=event, stats=stats)
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'])
@require_page("moderation")
def update_moderation_event(event_id):
if not can_write_page("moderation"):
return render_template("403.html"), 403
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>")
@require_page("moderation")
def delete_moderation_event(event_id):
if not can_write_page("moderation"):
return render_template("403.html"), 403
event = ModerationEvent.query.get_or_404(event_id)
db.session.delete(event)
db.session.commit()
return redirect(url_for('moderation'))
def _make_aware(dt):
"""Convertit un datetime naive en aware (UTC)"""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def get_invite_stats():
"""Calcule les statistiques des invitations"""
invites = DiscordInvite.query.filter_by(revoked=False).all()
if not invites:
return {
'total': 0,
'total_uses': 0,
'top_inviters': [],
'permanent': 0,
'temporary': 0,
'expired': 0
}
now = datetime.now(timezone.utc)
# Comptages par inviteur
inviter_uses = Counter()
for inv in invites:
if inv.inviter_name:
inviter_uses[inv.inviter_name] += inv.uses or 0
top_inviters = inviter_uses.most_common(5)
# Total des utilisations
total_uses = sum(inv.uses or 0 for inv in invites)
# Invitations permanentes (max_age = 0) vs temporaires
permanent = sum(1 for inv in invites if inv.max_age == 0)
temporary = sum(1 for inv in invites if inv.max_age > 0)
# Invitations expirées (mais pas encore révoquées)
expired = sum(1 for inv in invites if inv.expires_at and _make_aware(inv.expires_at) < now)
return {
'total': len(invites),
'total_uses': total_uses,
'top_inviters': top_inviters,
'permanent': permanent,
'temporary': temporary,
'expired': expired
}
def is_invite_expired(invite, now):
"""Vérifie si une invitation est expirée"""
if not invite.expires_at:
return False
expires_at = _make_aware(invite.expires_at)
return expires_at < now
@webapp.route("/moderation/invitations")
def moderation_invitations():
"""Affiche la liste des invitations Discord"""
show_revoked = request.args.get('show_revoked', 'false') == 'true'
query = DiscordInvite.query
if not show_revoked:
query = query.filter_by(revoked=False)
invites = query.order_by(DiscordInvite.created_at.desc()).all()
invite_stats = get_invite_stats()
guilds = discord_bridge.get_guilds()
now = datetime.now(timezone.utc)
# Pré-calculer le statut expiré pour chaque invitation
for inv in invites:
inv.is_expired = is_invite_expired(inv, now)
return render_template("moderation.html",
events=[],
event=None,
stats=get_moderation_stats(),
invites=invites,
invite_stats=invite_stats,
guilds=guilds,
show_invites=True,
show_revoked=show_revoked,
now=now
)
@webapp.route("/moderation/invitations/sync")
def sync_invitations():
"""Synchronise les invitations depuis Discord"""
guild_id = request.args.get('guild_id', type=int)
result = discord_bridge.sync_invites(guild_id)
if result.get('success'):
flash(f"✅ Synchronisation réussie : {result.get('synced', 0)} invitation(s) synchronisée(s)", 'success')
else:
flash(f"❌ Erreur : {result.get('message', 'Erreur inconnue')}", 'error')
return redirect(url_for('moderation_invitations'))
@webapp.route("/moderation/invitations/revoke/<invite_code>")
def revoke_invitation(invite_code):
"""Révoque une invitation Discord"""
result = discord_bridge.revoke_invite(invite_code)
if result.get('success'):
flash(f"✅ Invitation {invite_code} révoquée avec succès", 'success')
else:
flash(f"❌ Erreur : {result.get('message', 'Erreur inconnue')}", 'error')
return redirect(url_for('moderation_invitations'))
@webapp.route("/api/invitations/sync", methods=['POST'])
def api_sync_invitations():
"""API pour synchroniser les invitations"""
guild_id = request.json.get('guild_id') if request.is_json else None
result = discord_bridge.sync_invites(guild_id)
return jsonify(result)
@webapp.route("/api/invitations/revoke/<invite_code>", methods=['POST'])
def api_revoke_invitation(invite_code):
"""API pour révoquer une invitation"""
result = discord_bridge.revoke_invite(invite_code)
return jsonify(result)
+11 -3
View File
@@ -1,23 +1,31 @@
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import GameAlias
from database.helpers import ConfigurationHelper
@webapp.route("/protondb")
@require_page("protondb")
def openProtonDB():
aliases = GameAlias.query.all()
return render_template("protondb.html", aliases = aliases, configuration = ConfigurationHelper())
return render_template("protondb.html", aliases=aliases, configuration=ConfigurationHelper())
@webapp.route("/protondb/gamealias/add", methods=['POST'])
@require_page("protondb")
def addGameAlias():
game_alias = GameAlias(alias = request.form.get('alias'), name = request.form.get('name'))
if not can_write_page("protondb"):
return render_template("403.html"), 403
game_alias = GameAlias(alias=request.form.get('alias'), name=request.form.get('name'))
db.session.add(game_alias)
db.session.commit()
return redirect(url_for('openProtonDB'))
@webapp.route('/protondb/gamealias/del/<int:id>')
def delGameAlias(id : int):
@require_page("protondb")
def delGameAlias(id: int):
if not can_write_page("protondb"):
return render_template("403.html"), 403
GameAlias.query.filter_by(id=id).delete()
db.session.commit()
return redirect(url_for('openProtonDB'))
+329
View File
@@ -0,0 +1,329 @@
# Paramètres webapp : rôles, permissions par page, inscriptions (super administrateur uniquement).
from flask import render_template, request, redirect, url_for, flash
from webapp import webapp
from webapp.auth import require_page
from database import db
from database.models import WebappRole, PagePermission, WebappUser
from database.helpers import ConfigurationHelper
# Métadonnées des pages : catégorie, label d'affichage, description
PAGE_METADATA = {
"index": {
"label": "Tableau de bord",
"category": "general",
"description": "Page d'accueil avec aperçu du système",
"icon": "home"
},
"commandes": {
"label": "Commandes",
"category": "content",
"description": "Gérer les commandes Discord et Twitch",
"icon": "terminal"
},
"configurations": {
"label": "Configurations",
"category": "config",
"description": "Paramètres généraux du bot",
"icon": "settings"
},
"humeurs": {
"label": "Humeurs",
"category": "content",
"description": "Gérer les statuts du bot Discord",
"icon": "smile"
},
"protondb": {
"label": "ProtonDB",
"category": "content",
"description": "Recherche de compatibilité des jeux Linux",
"icon": "gamepad"
},
"live_alert": {
"label": "Alertes Live",
"category": "content",
"description": "Notifications Discord pour les streams Twitch",
"icon": "bell"
},
"youtube": {
"label": "YouTube",
"category": "content",
"description": "Notifications Discord pour les vidéos YouTube",
"icon": "video"
},
"announcements": {
"label": "Annonces Twitch",
"category": "content",
"description": "Messages automatiques dans le chat Twitch",
"icon": "megaphone"
},
"moderation": {
"label": "Modération Discord",
"category": "moderation",
"description": "Historique de modération Discord",
"icon": "shield"
},
"twitch_moderation": {
"label": "Modération Twitch",
"category": "moderation",
"description": "Commandes et logs de modération Twitch",
"icon": "shield-check"
},
"link_filter": {
"label": "Filtre de liens",
"category": "moderation",
"description": "Filtrage automatique des liens Twitch",
"icon": "filter"
},
"twitch_events": {
"label": "Événements Twitch",
"category": "content",
"description": "Notifications subs, follows, raids, clips",
"icon": "star"
},
"freeloot": {
"label": "Free Loot",
"category": "content",
"description": "Flux RSS de jeux gratuits vers Discord",
"icon": "gift"
},
"users": {
"label": "Utilisateurs",
"category": "admin",
"description": "Gestion des comptes et rôles webapp",
"icon": "users"
},
"settings": {
"label": "Paramètres",
"category": "admin",
"description": "Rôles, permissions et inscriptions",
"icon": "cog"
},
}
# Labels des catégories
CATEGORY_LABELS = {
"general": {"label": "Général", "color": "#6B7280", "icon": "layout"},
"content": {"label": "Contenu", "color": "#3B82F6", "icon": "file-text"},
"moderation": {"label": "Modération", "color": "#EF4444", "icon": "shield"},
"config": {"label": "Configuration", "color": "#8B5CF6", "icon": "settings"},
"admin": {"label": "Administration", "color": "#F59E0B", "icon": "crown"},
}
# Rôles par défaut avec métadonnées
DEFAULT_ROLES = {
"viewer_twitch": {
"description": "Accès minimal, consultation uniquement",
"color": "#9146FF",
"icon": "eye"
},
"utilisateur_discord": {
"description": "Peut consulter et modifier du contenu basique",
"color": "#5865F2",
"icon": "user"
},
"moderateur_discord": {
"description": "Accès aux outils de modération Discord",
"color": "#57F287",
"icon": "shield"
},
"expert_discord": {
"description": "Gestion avancée du contenu et des configurations",
"color": "#FEE75C",
"icon": "star"
},
"moderateur_twitch": {
"description": "Accès aux outils de modération Twitch",
"color": "#9146FF",
"icon": "shield-check"
},
"super_administrateur": {
"description": "Accès complet à toutes les fonctionnalités",
"color": "#ED4245",
"icon": "crown"
},
}
PAGE_KEYS = [
("index", "Tableau de bord"),
("configurations", "Configurations"),
("commandes", "Commandes"),
("humeurs", "Humeurs"),
("live_alert", "Alerte live"),
("announcements", "Annonces Twitch"),
("twitch_moderation", "Modération Twitch"),
("link_filter", "Filtre de liens"),
("twitch_events", "Notifications événements Twitch"),
("youtube", "YouTube"),
("protondb", "ProtonDB"),
("freeloot", "FreeLoot"),
("moderation", "Modération Discord"),
("users", "Utilisateurs"),
("settings", "Paramètres"),
]
@webapp.route("/settings")
@require_page("settings")
def settings():
roles = WebappRole.query.order_by(WebappRole.level).all()
permissions = PagePermission.query.all()
perm_by_key = {p.page_key: p for p in permissions}
reg_enabled = ConfigurationHelper().getValue("registration_enabled") not in (None, "", "false", "0", "no", "off")
# Organiser les pages par catégorie
pages_by_category = {}
for page_key, meta in PAGE_METADATA.items():
category = meta.get("category", "general")
if category not in pages_by_category:
pages_by_category[category] = []
pages_by_category[category].append({
"key": page_key,
"meta": meta,
"permission": perm_by_key.get(page_key)
})
# Trier les pages dans chaque catégorie par label
for category in pages_by_category:
pages_by_category[category].sort(key=lambda x: x["meta"]["label"])
return render_template(
"settings.html",
roles=roles,
pages_by_category=pages_by_category,
category_labels=CATEGORY_LABELS,
perm_by_key=perm_by_key,
registration_enabled=reg_enabled,
page_metadata=PAGE_METADATA,
default_roles_meta=DEFAULT_ROLES,
)
@webapp.route("/settings/registration", methods=["POST"])
@require_page("settings")
def settings_toggle_registration():
enabled = request.form.get("enabled") in ("1", "true", "on", "yes")
ConfigurationHelper().createOrUpdate("registration_enabled", "true" if enabled else "false")
db.session.commit()
flash("Inscriptions " + ("activées" if enabled else "désactivées") + ".", "success")
return redirect(url_for("settings"))
@webapp.route("/settings/roles/add", methods=["POST"])
@require_page("settings")
def settings_role_add():
name = (request.form.get("name") or "").strip()
level_str = request.form.get("level", "0")
description = (request.form.get("description") or "").strip()
color = (request.form.get("color") or "#6B7280").strip()
icon = (request.form.get("icon") or "").strip()
if not name:
flash("Nom du rôle requis.", "error")
return redirect(url_for("settings"))
try:
level = int(level_str)
except ValueError:
level = 0
if WebappRole.query.filter_by(name=name).first():
flash(f"Le rôle « {name} » existe déjà.", "error")
return redirect(url_for("settings"))
role = WebappRole(
name=name,
level=level,
description=description if description else None,
color=color,
icon=icon if icon else None
)
db.session.add(role)
db.session.commit()
flash(f"Rôle « {name} » créé.", "success")
return redirect(url_for("settings"))
@webapp.route("/settings/roles/<int:role_id>/edit", methods=["POST"])
@require_page("settings")
def settings_role_edit(role_id):
role = WebappRole.query.get_or_404(role_id)
level_str = request.form.get("level")
description = request.form.get("description")
color = request.form.get("color")
icon = request.form.get("icon")
if level_str is not None:
try:
role.level = int(level_str)
except ValueError:
flash("Niveau invalide.", "error")
return redirect(url_for("settings"))
if description is not None:
role.description = description.strip() if description.strip() else None
if color is not None:
role.color = color.strip() if color.strip() else "#6B7280"
if icon is not None:
role.icon = icon.strip() if icon.strip() else None
db.session.commit()
flash(f"Rôle « {role.name} » mis à jour.", "success")
return redirect(url_for("settings"))
@webapp.route("/settings/roles/<int:role_id>/delete", methods=["POST"])
@require_page("settings")
def settings_role_delete(role_id):
role = WebappRole.query.get_or_404(role_id)
if WebappUser.query.filter_by(role=role.name).count() > 0:
flash(f"Impossible de supprimer le rôle « {role.name} » : des utilisateurs l'utilisent.", "error")
return redirect(url_for("settings"))
db.session.delete(role)
db.session.commit()
flash(f"Rôle « {role.name} » supprimé.", "success")
return redirect(url_for("settings"))
@webapp.route("/settings/permissions/update", methods=["POST"])
@require_page("settings")
def settings_permissions_update():
page_key = request.form.get("page_key")
role_name = request.form.get("role")
if not page_key:
return redirect(url_for("settings"))
role = WebappRole.query.filter_by(name=role_name).first()
level = role.level if role else 0
perm = PagePermission.query.filter_by(page_key=page_key).first()
if perm:
perm.min_level = level
perm.write_level = level
else:
perm = PagePermission(page_key=page_key, min_level=level, write_level=level)
db.session.add(perm)
db.session.commit()
flash(f"Accès à « {page_key} » mis à jour.", "success")
return redirect(url_for("settings"))
@webapp.route("/settings/permissions/bulk", methods=["POST"])
@require_page("settings")
def settings_permissions_bulk():
page_keys = request.form.getlist("page_keys")
role_name = request.form.get("role")
if not page_keys or not role_name:
flash("Sélectionnez au moins une page et un rôle.", "error")
return redirect(url_for("settings"))
role = WebappRole.query.filter_by(name=role_name).first()
level = role.level if role else 0
updated = 0
for page_key in page_keys:
perm = PagePermission.query.filter_by(page_key=page_key).first()
if perm:
perm.min_level = level
perm.write_level = level
else:
perm = PagePermission(page_key=page_key, min_level=level, write_level=level)
db.session.add(perm)
updated += 1
db.session.commit()
flash(f"Accès mis à jour pour {updated} page(s) avec le rôle « {role_name} ».", "success")
return redirect(url_for("settings"))
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
{% extends "template.html" %}
{% block content %}
<div class="max-w-md mx-auto py-12 text-center">
<h1 class="text-2xl font-bold text-slate-800 dark:text-white mb-2">Accès refusé</h1>
<p class="text-slate-600 dark:text-slate-400 mb-6">Vous n'avez pas les droits nécessaires pour accéder à cette page.</p>
<a href="{{ url_for('index') }}" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">Retour à l'accueil</a>
</div>
{% endblock %}
+188
View File
@@ -0,0 +1,188 @@
{% extends "template.html" %}
{% block content %}
<div class="space-y-8">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path>
</svg>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Annonces Twitch</h1>
<p class="text-gray-600 dark:text-gray-400">Messages automatiques périodiques dans le chat</p>
</div>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Configurez des messages envoyés automatiquement dans le chat Twitch.
L'annonce est envoyée uniquement si le temps est écoulé ET si le nombre minimum de messages a été atteint.
</p>
</div>
{% if not announcement %}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Annonces configurées</h2>
</div>
{% if announcements %}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Nom</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Temps</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Min. messages</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Dernier envoi</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for ann in announcements %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<td class="px-6 py-4">
<span class="font-medium text-gray-900 dark:text-white">{{ ann.name }}</span>
</td>
<td class="px-6 py-4">
<span class="text-gray-600 dark:text-gray-300 text-sm max-w-xs truncate block">{{ ann.text[:60] }}{% if ann.text|length > 60 %}...{% endif %}</span>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
{{ ann.periodicity }} min
</span>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
{{ ann.min_chat_messages }}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{% if ann.last_sent %}
{{ ann.last_sent.strftime('%d/%m %H:%M') }}
{% else %}
<span class="italic">Jamais</span>
{% endif %}
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('toggleAnnouncement', id=ann.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="{{ 'Désactiver' if ann.enable else 'Activer' }}">
{% if ann.enable %}
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{% else %}
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{% endif %}
</a>
<a href="{{ url_for('resetAnnouncement', id=ann.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Remettre le compteur à zéro">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</a>
<a href="{{ url_for('openEditAnnouncement', id=ann.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Modifier">
<svg class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</a>
<a href="{{ url_for('delAnnouncement', id=ann.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette annonce ?')"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
title="Supprimer">
<svg class="w-5 h-5 text-red-500" 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>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="px-6 py-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">Aucune annonce</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Commencez par créer votre première annonce automatique.</p>
</div>
{% endif %}
</div>
{% endif %}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-6">
{{ 'Modifier l\'annonce' if announcement else 'Ajouter une annonce' }}
</h2>
<form action="{{ url_for('submitEditAnnouncement', id=announcement.id) if announcement else url_for('addAnnouncement') }}" method="POST" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Nom de l'annonce
</label>
<input type="text" name="name" id="name" required maxlength="64"
value="{{ announcement.name if announcement else '' }}"
placeholder="Ex: Règles du chat"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
</div>
<div>
<label for="periodicity" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Temps entre les annonces (minutes)
</label>
<input type="number" name="periodicity" id="periodicity" required min="1" max="1440"
value="{{ announcement.periodicity if announcement else 10 }}"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">1 min à 1440 min (24h)</p>
</div>
<div>
<label for="min_chat_messages" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Messages minimum entre annonces
</label>
<input type="number" name="min_chat_messages" id="min_chat_messages" required min="0" max="1000"
value="{{ announcement.min_chat_messages if announcement else 0 }}"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">0 = pas de minimum</p>
</div>
</div>
<div>
<label for="text" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Message
</label>
<textarea name="text" id="text" required maxlength="500" rows="4"
placeholder="Le message qui sera envoyé dans le chat..."
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors resize-none">{{ announcement.text if announcement else '' }}</textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Maximum 500 caractères</p>
</div>
<div class="flex items-center gap-4">
<button type="submit"
class="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2">
{{ 'Enregistrer' if announcement else 'Ajouter' }}
</button>
{% if announcement %}
<a href="{{ url_for('openAnnouncements') }}"
class="px-6 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-lg transition-colors">
Annuler
</a>
{% endif %}
</div>
</form>
</div>
</div>
{% endblock %}
+125 -50
View File
@@ -1,55 +1,130 @@
{% 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-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Commandes de Mamie</h1>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Gérez les commandes personnalisées du bot. Ces commandes peuvent être activées sur Discord et/ou Twitch selon vos besoins.
</p>
</div>
</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="mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Liste des commandes</h2>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Commande</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Réponse</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Discord</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Twitch</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Permission Twitch</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for commande in commandes %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<code class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-purple-600 dark:text-purple-400 font-mono">{{ commande.trigger }}</code>
</td>
<td class="px-6 py-4 text-gray-600 dark:text-gray-400 max-w-md truncate">{{ commande.response }}</td>
<td class="px-6 py-4 text-center">
<a href="{{ url_for('toggle_discord_commande', commande_id = commande.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors inline-block"
title="{{ 'Désactiver sur Discord' if commande.discord_enable else 'Activer sur Discord' }}">
{{ '✅' if commande.discord_enable else '❌' }}
</a>
</td>
<td class="px-6 py-4 text-center">
<a href="{{ url_for('toggle_twitch_commande', commande_id = commande.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors inline-block"
title="{{ 'Désactiver sur Twitch' if commande.twitch_enable else 'Activer sur Twitch' }}">
{{ '✅' if commande.twitch_enable else '❌' }}
</a>
</td>
<td class="px-6 py-4 text-center">
{% if commande.twitch_enable %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
{{ twitch_permissions.get(commande.twitch_permission or 'viewer', 'Tous') }}
</span>
{% else %}
<span class="text-gray-400 dark:text-gray-500"></span>
{% endif %}
</td>
<td class="px-6 py-4 text-center">
<a href="{{ url_for('delete_commande', commande_id = commande.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette commande ?')"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400 inline-block"
title="Supprimer">
<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="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>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
Aucune commande configurée. Ajoutez-en une ci-dessous.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</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-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">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-gray-700 dark:text-gray-300 mb-2">Commande</label>
<input name="trigger" id="trigger" type="text" required
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="!macommande"/>
</div>
<div class="flex flex-wrap items-end gap-6">
<label class="flex items-center gap-2 cursor-pointer">
<input name="discord_enable" type="checkbox" checked
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Discord</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input name="twitch_enable" type="checkbox" id="twitch_enable_checkbox"
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Twitch</span>
</label>
<div class="w-full sm:w-auto">
<label for="twitch_permission" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Permission Twitch (qui peut utiliser la commande)</label>
<select name="twitch_permission" id="twitch_permission"
class="w-full sm:w-48 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent">
{% for value, label in twitch_permissions.items() %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div>
<label for="response" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Réponse</label>
<textarea name="response" id="response" rows="4" required
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all resize-y"
placeholder="La réponse que le bot enverra..."></textarea>
</div>
<div>
<button type="submit"
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Ajouter la commande
</button>
</div>
</form>
</div>
{% endblock %}
+276 -411
View File
@@ -1,247 +1,172 @@
{% extends "template.html" %}
{% block content %}
<div class="config-page">
<h1>⚙️ Configuration</h1>
<p>Paramétrez Discord, Twitch, YouTube et les fonctionnalités du bot.</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-4 space-y-2">
{% for category, msg in messages %}
<div class="p-4 rounded-lg {{ 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200' if category == 'success' else 'bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200' }}">
{{ msg }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Configuration de Mamie</h1>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Configurez les tokens Discord, les notifications Humble Bundle et l'API Twitch.
</p>
</div>
</div>
<!-- Section Discord -->
<div class="config-section open" data-section="discord">
<div class="config-section-header" onclick="toggleSection(this)">
<div class="config-section-icon">🎮</div>
<div class="config-section-title">
<h2>Discord</h2>
<p>Token API, messages de bienvenue, auto-rôle et modération</p>
</div>
<svg class="config-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
<div class="space-y-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center gap-3 mb-6">
<svg class="w-6 h-6 text-indigo-500" 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>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Discord</h2>
</div>
<div class="config-section-content">
<form action="{{ url_for('updateConfiguration') }}" method="POST">
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-6">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">API Discord</h3>
<div>
<label for="discord_token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Token Discord (caché)</label>
<input name="discord_token" id="discord_token" type="password"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
placeholder="Votre token Discord"/>
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">Nécessite un redémarrage après modification</p>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Messages de bienvenue</h3>
<!-- Token Discord -->
<div class="config-card">
<div class="config-card-header">
<h3>🔑 Token Discord</h3>
<span class="badge">Sensible</span>
</div>
<label for="discord_token">Token du bot (caché)</label>
<div class="token-input-wrapper">
<input name="discord_token" id="discord_token" type="password" placeholder="Votre token Discord" autocomplete="off" />
<button type="button" class="token-toggle" onclick="toggleTokenVisibility('discord_token', this)" title="Afficher/Masquer">👁️</button>
</div>
<small>⚠️ Nécessite un redémarrage du bot après modification</small>
</div>
<!-- Messages de bienvenue -->
<div class="config-card">
<div class="config-card-header">
<h3>👋 Messages de bienvenue</h3>
</div>
<div class="toggle-row">
<label for="welcome_enable">Activer le message de bienvenue pour les nouveaux membres</label>
<div class="toggle-switch">
<input type="checkbox" name="welcome_enable" id="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked{% endif %}>
<span class="toggle-slider"></span>
</div>
</div>
<label for="welcome_channel_id">Canal de bienvenue</label>
<select name="welcome_channel_id" id="welcome_channel_id">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer le message de bienvenue pour les nouveaux membres</span>
</label>
<div>
<label for="welcome_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de bienvenue</label>
<select name="welcome_channel_id" id="welcome_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-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>
<option value="{{channel.id}}" {% if configuration.getIntValue('welcome_channel_id')==channel.id %}selected{% endif %}>{{channel.name}}</option>
{% endfor %}
</select>
<label for="welcome_message">Message personnalisé</label>
<textarea name="welcome_message" id="welcome_message" rows="3" placeholder="Bienvenue {member.mention} sur le serveur !">{{ configuration.getValue('welcome_message') }}</textarea>
<details class="syntax-helper">
<summary>📝 Variables disponibles</summary>
<div class="syntax-list">
<div class="syntax-item">
<code>{member.mention}</code>
<span>Mentionne l'utilisateur (@NomUtilisateur)</span>
</div>
<div class="syntax-item">
<code>{member.name}</code>
<span>Nom d'utilisateur (sans mention)</span>
</div>
<div class="syntax-item">
<code>{member.display_name}</code>
<span>Surnom sur le serveur</span>
</div>
<div class="syntax-item">
<code>{member.id}</code>
<span>ID de l'utilisateur</span>
</div>
<div class="syntax-item">
<code>{server.name}</code>
<span>Nom du serveur</span>
</div>
<div class="syntax-item">
<code>{server.member_count}</code>
<span>Nombre total de membres</span>
</div>
<div class="syntax-item">
<code>&lt;#ID_DU_CHANNEL&gt;</code>
<span>Mentionne un salon</span>
</div>
</div>
</details>
</div>
<!-- Auto-Role -->
<div class="config-card">
<div class="config-card-header">
<h3>🎭 Auto-Rôle</h3>
</div>
<div class="toggle-row">
<label for="autorole_enable">Attribuer automatiquement un rôle aux nouveaux membres</label>
<div class="toggle-switch">
<input type="checkbox" name="autorole_enable" id="autorole_enable" {% if configuration.getValue('autorole_enable') %}checked{% endif %}>
<span class="toggle-slider"></span>
</div>
</div>
<label>Rôle à attribuer</label>
{% if roles|length > 1 %}
<div class="guild-tabs">
{% for guild_data in roles %}
<button type="button" class="guild-tab {% if loop.first %}active{% endif %}" onclick="switchGuildTab(this, 'autorole-guild-{{guild_data.guild_id}}')">
{{ guild_data.guild_name }}
</button>
{% endfor %}
</div>
{% endif %}
{% for guild_data in roles %}
<div id="autorole-guild-{{guild_data.guild_id}}" class="guild-content {% if loop.first %}active{% endif %}">
<select name="autorole_role_id">
<option value="">-- Aucun rôle --</option>
{% for role in guild_data.roles %}
<option value="{{role.id}}" {% if configuration.getIntValue('autorole_role_id')==role.id %}selected{% endif %}>
{% if role.color.value != 0 %}⬤{% else %}○{% endif %} {{role.name}}
</option>
{% endfor %}
</select>
</div>
{% endfor %}
<div class="warning-box">
<span class="warning-box-icon">⚠️</span>
<div class="warning-box-content">
<strong>Permissions requises</strong>
<p>Le bot doit avoir "Gérer les rôles" et son rôle doit être plus haut dans la hiérarchie que le rôle à attribuer.</p>
</div>
<div>
<label for="welcome_message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message personnalisé</label>
<textarea name="welcome_message" id="welcome_message" rows="3"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all resize-y"
placeholder="Bienvenue {member.mention} sur le serveur !">{{ configuration.getValue('welcome_message') }}</textarea>
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p><code class="px-1 bg-gray-200 dark:bg-gray-600 rounded">{member.mention}</code> Mentionne l'utilisateur</p>
<p><code class="px-1 bg-gray-200 dark:bg-gray-600 rounded">{member.name}</code> Nom d'utilisateur</p>
<p><code class="px-1 bg-gray-200 dark:bg-gray-600 rounded">{server.name}</code> Nom du serveur</p>
<p><code class="px-1 bg-gray-200 dark:bg-gray-600 rounded">{server.member_count}</code> Nombre de membres</p>
</div>
</div>
</div>
<!-- Messages de départ -->
<div class="config-card">
<div class="config-card-header">
<h3>🚪 Messages de départ</h3>
</div>
<div class="toggle-row">
<label for="leave_enable">Activer le message quand un membre quitte le serveur</label>
<div class="toggle-switch">
<input type="checkbox" name="leave_enable" id="leave_enable" {% if configuration.getValue('leave_enable') %}checked{% endif %}>
<span class="toggle-slider"></span>
</div>
</div>
<label for="leave_channel_id">Canal de départ</label>
<select name="leave_channel_id" id="leave_channel_id">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Messages de départ</h3>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="leave_enable" {% if configuration.getValue('leave_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer le message quand un membre quitte le serveur</span>
</label>
<div>
<label for="leave_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de départ</label>
<select name="leave_channel_id" id="leave_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-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>
<option value="{{channel.id}}" {% if configuration.getIntValue('leave_channel_id')==channel.id %}selected{% endif %}>{{channel.name}}</option>
{% endfor %}
</select>
<label for="leave_message">Message personnalisé</label>
<textarea name="leave_message" id="leave_message" rows="3" placeholder="{member.name} a quitté le serveur.">{{ configuration.getValue('leave_message') }}</textarea>
<details class="syntax-helper">
<summary>📝 Variables disponibles</summary>
<div class="syntax-list">
<div class="syntax-item">
<code>{member.mention}</code>
<span>Mentionne l'utilisateur</span>
</div>
<div class="syntax-item">
<code>{member.name}</code>
<span>Nom d'utilisateur</span>
</div>
<div class="syntax-item">
<code>{member.display_name}</code>
<span>Surnom sur le serveur</span>
</div>
<div class="syntax-item">
<code>{server.name}</code>
<span>Nom du serveur</span>
</div>
<div class="syntax-item">
<code>{server.member_count}</code>
<span>Nombre de membres restants</span>
</div>
</div>
</details>
</div>
<div>
<label for="leave_message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message personnalisé</label>
<textarea name="leave_message" id="leave_message" rows="3"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all resize-y"
placeholder="{member.name} a quitté le serveur.">{{ configuration.getValue('leave_message') }}</textarea>
</div>
</div>
<!-- Modération -->
<div class="config-card">
<div class="config-card-header">
<h3>🛡️ Modération</h3>
</div>
<div class="toggle-row">
<label for="moderation_enable">Activer les commandes d'avertissement (!warn, !unwarn, !inspect)</label>
<div class="toggle-switch">
<input type="checkbox" name="moderation_enable" id="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked{% endif %}>
<span class="toggle-slider"></span>
</div>
</div>
<div class="toggle-row">
<label for="moderation_ban_enable">Activer les commandes de bannissement (!ban, !unban)</label>
<div class="toggle-switch">
<input type="checkbox" name="moderation_ban_enable" id="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked{% endif %}>
<span class="toggle-slider"></span>
</div>
</div>
<div class="toggle-row">
<label for="moderation_kick_enable">Activer la commande d'expulsion (!kick)</label>
<div class="toggle-switch">
<input type="checkbox" name="moderation_kick_enable" id="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked{% endif %}>
<span class="toggle-slider"></span>
</div>
</div>
<label for="moderation_log_channel_id">Canal de logs de modération</label>
<select name="moderation_log_channel_id" id="moderation_log_channel_id">
{% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('moderation_log_channel_id')==channel.id %}selected{% endif %}>
#{{channel.name}}
</option>
<div id="auto-rooms" class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Auto Rooms (salons vocaux temporaires)</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Quand un membre rejoint le canal vocal configuré ci-dessous, un salon vocal temporaire est créé. Le message de configuration avec les réactions apparaît dans la <strong>partie texte du vocal</strong> (onglet Discussion à droite quand on ouvre le salon). Seul le propriétaire peut réagir.
</p>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="auto_rooms_enable" {% if configuration.getValue('auto_rooms_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les Auto Rooms</span>
</label>
<div>
<label for="auto_rooms_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal vocal à rejoindre pour créer un salon</label>
<select name="auto_rooms_channel_id" id="auto_rooms_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
<option value="">— Choisir un canal vocal —</option>
{% for channel in voice_channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('auto_rooms_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }} (vocal)</option>
{% endfor %}
</select>
<small>Toutes les actions de modération seront notifiées dans ce canal</small>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Ex. « + Créer votre salon » — les membres qui rejoignent ce canal obtiennent un salon vocal dédié.</p>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Modération</h3>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les commandes d'avertissement (!warn, !unwarn, !inspect)</span>
</label>
<label>Rôles Staff autorisés</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les commandes de bannissement (!ban, !unban)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer la commande d'expulsion (!kick)</span>
</label>
</div>
<div>
<label for="moderation_log_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de logs de modération</label>
<select name="moderation_log_channel_id" id="moderation_log_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-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>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Toutes les actions de modération seront notifiées dans ce canal</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôles Staff autorisés</label>
{% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %}
{% if roles|length > 1 %}
<div class="guild-tabs">
<div class="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-600 mb-3">
{% for guild_data in roles %}
<button type="button" class="guild-tab {% if loop.first %}active{% endif %}" onclick="switchGuildTab(this, 'moderation-guild-{{guild_data.guild_id}}')">
<button type="button" class="tab-button px-4 py-2 text-sm font-medium rounded-t-lg transition-colors
{% if loop.first %}bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}"
onclick="openTab(event, 'guild-{{guild_data.guild_id}}')" {% if loop.first %}id="defaultOpen"{% endif %}>
{{ guild_data.guild_name }}
</button>
{% endfor %}
@@ -249,228 +174,168 @@
{% endif %}
{% for guild_data in roles %}
<div id="moderation-guild-{{guild_data.guild_id}}" class="guild-content {% if loop.first %}active{% endif %}">
<div class="roles-container">
<div id="guild-{{guild_data.guild_id}}" class="tab-content {% if not loop.first %}hidden{% endif %}">
<div class="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-lg p-3 space-y-2">
{% for role in guild_data.roles %}
<div class="role-item">
<input type="checkbox" name="moderation_staff_role_ids" id="role-{{role.id}}" value="{{role.id}}" {% if role.id|string in selected_roles %}checked{% endif %}>
<label for="role-{{role.id}}">
<span class="role-color" style="background-color: {% if role.color.value != 0 %}#{{ '%06x' % role.color.value }}{% else %}var(--text-muted){% endif %};"></span>
{{role.name}}
</label>
</div>
<label class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 p-1 rounded">
<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 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
{% if role.color.value != 0 %}
<span style="color:#{{ '%06x' % role.color.value }}"></span>
{% else %}
<span class="text-gray-400"></span>
{% endif %}
<span class="text-sm text-gray-700 dark:text-gray-300">{{role.name}}</span>
</label>
{% endfor %}
</div>
</div>
{% endfor %}
<small>Sélectionnez les rôles qui peuvent utiliser les commandes de modération</small>
<label for="moderation_embed_delete_delay">Délai de suppression des embeds (secondes)</label>
<input name="moderation_embed_delete_delay" id="moderation_embed_delete_delay" type="number" value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" placeholder="0" min="0" />
<small>Mettre 0 pour ne jamais supprimer automatiquement</small>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Sélectionnez les rôles qui peuvent utiliser les commandes de modération</p>
</div>
<div>
<label for="moderation_embed_delete_delay" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Délai de suppression des embeds (secondes)</label>
<input name="moderation_embed_delete_delay" id="moderation_embed_delete_delay" type="number" min="0"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" placeholder="0"/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Mettre 0 pour ne pas supprimer automatiquement</p>
</div>
</div>
<div class="config-actions">
<input type="submit" value="💾 Enregistrer Discord">
</div>
</form>
</div>
<button type="submit"
class="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer la configuration Discord
</button>
</form>
</div>
<!-- Section Twitch -->
<div class="config-section" data-section="twitch">
<div class="config-section-header" onclick="toggleSection(this)">
<div class="config-section-icon">📺</div>
<div class="config-section-title">
<h2>Twitch</h2>
<p>Configuration de l'API Twitch pour les alertes live</p>
</div>
<svg class="config-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center gap-3 mb-6">
<svg class="w-6 h-6 text-purple-500" 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>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">API Twitch</h2>
</div>
<div class="config-section-content">
<form action="{{ url_for('updateConfiguration') }}" method="POST">
<label for="twitch_client_id">Client ID</label>
<input name="twitch_client_id" id="twitch_client_id" type="text" value="{{ configuration.getValue('twitch_client_id') }}" placeholder="Votre Client ID Twitch" />
<label for="twitch_client_secret">Client Secret</label>
<div class="token-input-wrapper">
<input name="twitch_client_secret" id="twitch_client_secret" type="password" value="{{ configuration.getValue('twitch_client_secret') }}" placeholder="Votre Client Secret" />
<button type="button" class="token-toggle" onclick="toggleTokenVisibility('twitch_client_secret', this)" title="Afficher/Masquer">👁️</button>
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-4">
<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-gray-700 dark:text-gray-300 mb-2">Client ID</label>
<input name="twitch_client_id" id="twitch_client_id" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('twitch_client_id') }}"/>
</div>
<div>
<label for="twitch_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Secret</label>
<input name="twitch_client_secret" id="twitch_client_secret" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('twitch_client_secret') }}"/>
</div>
</div>
<div>
<label for="twitch_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Chaîne à rejoindre</label>
<input name="twitch_channel" id="twitch_channel" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="#machinTruc"
value="{{ configuration.getValue('twitch_channel') }}"/>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Fonctionnalités du bot Twitch</h3>
<label for="twitch_channel">Chaîne à rejoindre</label>
<input name="twitch_channel" id="twitch_channel" type="text" value="{{ configuration.getValue('twitch_channel') }}" placeholder="#votre_chaine" />
<a href="{{ url_for('twitchConfigurationHelp') }}" class="help-link">
❓ Besoin d'aide pour configurer Twitch ?
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="twitch_commands_enable" {% if configuration.getValue('twitch_commands_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les commandes personnalisées (!commande)</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 ml-7">Les commandes configurées dans la page "Commandes" seront actives dans le chat Twitch</p>
</div>
<div class="flex items-center gap-4">
<button type="submit"
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer la configuration Twitch
</button>
<a href="{{ url_for('twitchConfigurationHelp') }}" class="text-purple-600 dark:text-purple-400 hover:underline text-sm">Aide</a>
</div>
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<a href="{{ url_for('twitchRequestToken') }}"
class="inline-flex items-center gap-2 px-4 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded-lg hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors text-sm font-medium">
<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 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path></svg>
Obtenir token et refresh token
</a>
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
<div class="config-card" style="margin-top: var(--space-lg);">
<div class="config-card-header">
<h3>🔐 Tokens d'accès</h3>
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Access Token</label>
<input type="text" readonly
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300"
value="{{ configuration.getValue('twitch_access_token') }}"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Refresh Token</label>
<input type="text" readonly
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300"
value="{{ configuration.getValue('twitch_refresh_token') }}"/>
</div>
<a href="{{ url_for('twitchRequestToken') }}" class="btn" style="margin-bottom: var(--space-md); display: inline-flex;">
🔄 Obtenir/Rafraîchir les tokens
</a>
<label for="twitch_access_token">Access Token</label>
<input name="twitch_access_token" id="twitch_access_token" type="text" value="{{ configuration.getValue('twitch_access_token') }}" readonly />
<label for="twitch_refresh_token">Refresh Token</label>
<input name="twitch_refresh_token" id="twitch_refresh_token" type="text" value="{{ configuration.getValue('twitch_refresh_token') }}" readonly />
<small>⚠️ Nécessite un redémarrage après l'obtention des tokens</small>
</div>
{% endif %}
<div class="config-actions">
<input type="submit" value="💾 Enregistrer Twitch">
</div>
</form>
</div>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">Nécessite un redémarrage après l'obtention des Tokens.</p>
</div>
{% endif %}
</form>
</div>
<!-- Section Humble Bundle -->
<div class="config-section" data-section="humble">
<div class="config-section-header" onclick="toggleSection(this)">
<div class="config-section-icon">🎁</div>
<div class="config-section-title">
<h2>Humble Bundle</h2>
<p>Notifications automatiques des nouveaux bundles de jeux</p>
</div>
<svg class="config-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center gap-3 mb-6">
<svg class="w-6 h-6 text-orange-500" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Humble Bundle</h2>
</div>
<div class="config-section-content">
<form action="{{ url_for('updateConfiguration') }}" method="POST">
<p style="color: var(--text-secondary); margin-bottom: var(--space-lg);">
Humble Bundle propose régulièrement des bundles de jeux vidéo à prix réduits. Activez les notifications pour recevoir automatiquement les nouveaux packs sur votre serveur Discord.
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-4">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Humble Bundle propose régulièrement des bundles de jeux vidéo à des prix réduits. Activez les notifications pour recevoir automatiquement les nouveaux packs disponibles sur votre serveur Discord.
</p>
<div class="toggle-row">
<label for="humble_bundle_enable">Activer les notifications Humble Bundle</label>
<div class="toggle-switch">
<input type="checkbox" name="humble_bundle_enable" id="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked{% endif %}>
<span class="toggle-slider"></span>
</div>
</div>
<label for="humble_bundle_channel">Canal de notification</label>
<select name="humble_bundle_channel" id="humble_bundle_channel">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer les notifications Humble Bundle</span>
</label>
</div>
<div>
<label for="humble_bundle_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de notification</label>
<select name="humble_bundle_channel" id="humble_bundle_channel"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-orange-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>
<option value="{{channel.id}}" {% if configuration.getIntValue('humble_bundle_channel')==channel.id %}selected{% endif %}>{{channel.name}}</option>
{% endfor %}
</select>
<div class="config-actions">
<input type="submit" value="💾 Enregistrer Humble Bundle">
</div>
</form>
</div>
</div>
<!-- Section Zone Danger -->
<div class="config-section danger" data-section="danger">
<div class="config-section-header" onclick="toggleSection(this)">
<div class="config-section-icon">⚠️</div>
<div class="config-section-title">
<h2>Zone Danger</h2>
<p>Actions irréversibles - Utilisez avec précaution</p>
</div>
<svg class="config-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
<div class="config-section-content">
<form action="{{ url_for('leaveGuild') }}" method="POST" onsubmit="return confirmLeave();">
<div class="danger-box">
<span class="danger-box-icon">🚨</span>
<div class="danger-box-content">
<strong>Quitter un serveur Discord</strong>
<p>Cette action est irréversible ! Le bot quittera définitivement le serveur sélectionné. Pour rejoindre à nouveau, vous devrez ré-inviter le bot.</p>
</div>
</div>
{% if guilds and guilds|length > 0 %}
<label for="guild_id">Sélectionner un serveur à quitter</label>
<select name="guild_id" id="guild_id" required>
<option value="">-- Choisir un serveur --</option>
{% for guild in guilds %}
<option value="{{ guild.id }}">{{ guild.name }} ({{ guild.member_count }} membres)</option>
{% endfor %}
</select>
<div class="toggle-row" style="background: rgba(220, 38, 38, 0.1); margin-top: var(--space-md);">
<label for="confirm_leave" style="color: var(--danger);">Je confirme vouloir faire quitter le bot de ce serveur</label>
<div class="toggle-switch">
<input type="checkbox" name="confirm_leave" id="confirm_leave" value="1" required>
<span class="toggle-slider"></span>
</div>
</div>
<div class="config-actions">
<input type="submit" value="🚪 Quitter le serveur" class="btn-danger" style="flex: none;">
</div>
{% else %}
<p style="color: var(--text-muted); text-align: center; padding: var(--space-lg);">
Aucun serveur Discord connecté ou le bot n'est pas encore démarré.
</p>
{% endif %}
</form>
</div>
<button type="submit"
class="px-6 py-2.5 bg-orange-600 hover:bg-orange-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer la configuration Humble Bundle
</button>
</form>
</div>
</div>
<script>
// Toggle section open/close
function toggleSection(header) {
const section = header.closest('.config-section');
section.classList.toggle('open');
}
// Toggle token visibility
function toggleTokenVisibility(inputId, button) {
const input = document.getElementById(inputId);
if (input.type === 'password') {
input.type = 'text';
button.textContent = '🙈';
} else {
input.type = 'password';
button.textContent = '👁️';
}
}
// Switch guild tabs
function switchGuildTab(button, contentId) {
// Get parent tabs container
const tabsContainer = button.closest('.guild-tabs');
const card = button.closest('.config-card');
// Deactivate all tabs in this group
tabsContainer.querySelectorAll('.guild-tab').forEach(tab => tab.classList.remove('active'));
button.classList.add('active');
// Hide all content in this card
card.querySelectorAll('.guild-content').forEach(content => content.classList.remove('active'));
document.getElementById(contentId).classList.add('active');
}
// Confirm leave guild
function confirmLeave() {
const select = document.getElementById('guild_id');
if (!select.value) {
alert('Veuillez sélectionner un serveur.');
return false;
}
const serverName = select.options[select.selectedIndex].text;
return confirm('Êtes-vous VRAIMENT sûr de vouloir quitter le serveur "' + serverName + '" ?\n\nCette action est IRRÉVERSIBLE !');
function openTab(evt, tabName) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.tab-button').forEach(el => {
el.classList.remove('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
el.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
});
document.getElementById(tabName).classList.remove('hidden');
evt.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
evt.currentTarget.classList.add('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
}
document.getElementById("defaultOpen")?.click();
</script>
{% endblock %}
-381
View File
@@ -1,381 +0,0 @@
{% extends "template.html" %}
{% block content %}
<h1>🎮 Jeux Gratuits</h1>
<p>Gérez les notifications de jeux gratuits via le flux RSS LootScraper. Activez le module, choisissez les sources à suivre et décidez quels jeux partager avec votre communauté.</p>
<h2>Configuration</h2>
<form action="{{ url_for('updateFreeGamesConfig') }}" method="POST">
<fieldset>
<legend>Paramètres généraux</legend>
<label for="freegames_enable">
<input type="checkbox" name="freegames_enable" id="freegames_enable" {% if configuration.getValue('freegames_enable') %}checked="checked"{% endif %}>
Activer le module Jeux Gratuits
</label>
<label for="freegames_auto_notify">
<input type="checkbox" name="freegames_auto_notify" id="freegames_auto_notify" {% if configuration.getValue('freegames_auto_notify') %}checked="checked"{% endif %}>
Notification automatique des nouveaux jeux
</label>
<small>Si activé, les nouveaux jeux des sources sélectionnées seront automatiquement partagés</small>
<label for="freegames_channel">Canal de notification</label>
<select name="freegames_channel" id="freegames_channel">
<option value="">-- Sélectionner un canal --</option>
{% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('freegames_channel')==channel.id %}selected="selected"{% endif %}>
{{channel.name}}
</option>
{% endfor %}
</select>
</fieldset>
<fieldset>
<legend>Mentions</legend>
<label for="freegames_mention_type">Type de mention</label>
<select name="freegames_mention_type" id="freegames_mention_type" onchange="toggleRoleSelect()">
<option value="none" {% if configuration.getValue('freegames_mention_type') == 'none' or not configuration.getValue('freegames_mention_type') %}selected{% endif %}>Aucune mention</option>
<option value="here" {% if configuration.getValue('freegames_mention_type') == 'here' %}selected{% endif %}>@here</option>
<option value="everyone" {% if configuration.getValue('freegames_mention_type') == 'everyone' %}selected{% endif %}>@everyone</option>
<option value="role" {% if configuration.getValue('freegames_mention_type') == 'role' %}selected{% endif %}>Rôle spécifique</option>
</select>
<div id="role-select-container" style="{% if configuration.getValue('freegames_mention_type') != 'role' %}display: none;{% endif %}">
<label for="freegames_mention_role">Rôle à mentionner</label>
{% for guild_data in roles %}
<select name="freegames_mention_role" id="freegames_mention_role">
<option value="">-- Sélectionner un rôle --</option>
{% for role in guild_data.roles %}
<option value="{{role.id}}" {% if configuration.getIntValue('freegames_mention_role')==role.id %}selected="selected"{% endif %}>
{% if role.color.value != 0 %}●{% else %}○{% endif %} {{role.name}}
</option>
{% endfor %}
</select>
{% endfor %}
</div>
</fieldset>
<fieldset>
<legend>Sources à suivre</legend>
<small>Sélectionnez les plateformes dont vous souhaitez recevoir les offres. Si aucune n'est sélectionnée, toutes les sources seront incluses.</small>
{% set enabled_sources = (configuration.getValue('freegames_sources') or '').split(',') %}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; margin-top: 15px;">
{% for key, name in sources.items() %}
<label style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" name="freegames_sources" value="{{key}}" {% if key in enabled_sources or not configuration.getValue('freegames_sources') %}checked{% endif %}>
{{name}}
</label>
{% endfor %}
</div>
</fieldset>
<input type="submit" value="💾 Enregistrer la configuration">
</form>
<h2>Jeux disponibles <span class="badge">{{pending_count}} en attente</span></h2>
<div style="margin-bottom: 20px;">
<button type="button" onclick="refreshGames()" class="btn-secondary">🔄 Rafraîchir le flux RSS</button>
</div>
<div style="margin-bottom: 15px;">
<label for="filter-source">Filtrer par source :</label>
<select id="filter-source" onchange="filterGames()">
<option value="all">Toutes les sources</option>
{% for key, name in sources.items() %}
<option value="{{name}}">{{name}}</option>
{% endfor %}
</select>
<label for="filter-status" style="margin-left: 20px;">Filtrer par statut :</label>
<select id="filter-status" onchange="filterGames()">
<option value="all">Tous</option>
<option value="pending">En attente</option>
<option value="notified">Notifiés</option>
</select>
</div>
<div class="games-list">
{% for game in games %}
<div class="game-card" data-source="{{game.source}}" data-status="{% if game.notified %}notified{% else %}pending{% endif %}">
<div class="game-header">
{% if game.image_url %}
<img src="{{game.image_url}}" alt="{{game.title}}" class="game-thumb">
{% else %}
<div class="game-thumb-placeholder">🎮</div>
{% endif %}
<div class="game-info">
<h3>{{game.title}}</h3>
<span class="source-badge">{{game.source}}</span>
{% if game.notified %}
<span class="status-badge notified">✓ Notifié</span>
{% else %}
<span class="status-badge pending">⏳ En attente</span>
{% endif %}
</div>
</div>
{% if game.description %}
<p class="game-description">{{game.description[:150]}}{% if game.description|length > 150 %}...{% endif %}</p>
{% endif %}
<div class="game-meta">
{% if game.valid_to %}
<span title="Date de fin">⏰ Jusqu'au {{game.valid_to.strftime('%d/%m/%Y')}}</span>
{% endif %}
<a href="{{game.url}}" target="_blank" rel="noopener">🔗 Voir l'offre</a>
</div>
<div class="game-actions">
{% if not game.notified %}
<button type="button" onclick="notifyGame({{game.id}}, this)" class="btn-primary">📢 Notifier</button>
<button type="button" onclick="markNotified({{game.id}}, this)" class="btn-secondary">✓ Marquer comme notifié</button>
{% else %}
<button type="button" onclick="resetGame({{game.id}}, this)" class="btn-secondary">↩️ Réinitialiser</button>
{% endif %}
<button type="button" onclick="deleteGame({{game.id}}, this)" class="btn-danger">🗑️ Supprimer</button>
</div>
</div>
{% else %}
<p>Aucun jeu gratuit trouvé. Cliquez sur "Rafraîchir le flux RSS" pour récupérer les dernières offres.</p>
{% endfor %}
</div>
<style>
.badge {
background: #5865F2;
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.85em;
}
.games-list {
display: grid;
gap: 20px;
}
.game-card {
background: var(--color-bg-secondary, #f5f5f5);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--color-border, #ddd);
}
.game-card[data-status="notified"] {
opacity: 0.7;
}
.game-header {
display: flex;
gap: 15px;
align-items: flex-start;
margin-bottom: 15px;
}
.game-thumb {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 8px;
}
.game-thumb-placeholder {
width: 80px;
height: 80px;
background: var(--color-bg-tertiary, #e0e0e0);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2em;
}
.game-info {
flex: 1;
}
.game-info h3 {
margin: 0 0 8px 0;
font-size: 1.1em;
}
.source-badge {
background: #4CAF50;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8em;
margin-right: 8px;
}
.status-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8em;
}
.status-badge.pending {
background: #FF9800;
color: white;
}
.status-badge.notified {
background: #2196F3;
color: white;
}
.game-description {
color: var(--color-text-secondary, #666);
font-size: 0.9em;
margin: 10px 0;
}
.game-meta {
display: flex;
gap: 20px;
font-size: 0.85em;
color: var(--color-text-secondary, #666);
margin-bottom: 15px;
}
.game-meta a {
color: #5865F2;
}
.game-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.game-actions button {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9em;
transition: opacity 0.2s;
}
.game-actions button:hover {
opacity: 0.8;
}
.btn-primary {
background: #5865F2;
color: white;
}
.btn-secondary {
background: #747F8D;
color: white;
}
.btn-danger {
background: #ED4245;
color: white;
}
.hidden {
display: none !important;
}
</style>
<script>
function toggleRoleSelect() {
const mentionType = document.getElementById('freegames_mention_type').value;
const roleContainer = document.getElementById('role-select-container');
roleContainer.style.display = mentionType === 'role' ? 'block' : 'none';
}
function filterGames() {
const sourceFilter = document.getElementById('filter-source').value;
const statusFilter = document.getElementById('filter-status').value;
const cards = document.querySelectorAll('.game-card');
cards.forEach(card => {
const source = card.dataset.source;
const status = card.dataset.status;
const sourceMatch = sourceFilter === 'all' || source === sourceFilter;
const statusMatch = statusFilter === 'all' || status === statusFilter;
card.classList.toggle('hidden', !(sourceMatch && statusMatch));
});
}
function refreshGames() {
fetch('/freegames/refresh', { method: 'POST' })
.then(r => r.json())
.then(data => {
alert(data.message);
if (data.new_games > 0) {
location.reload();
}
})
.catch(e => alert('Erreur: ' + e));
}
function notifyGame(id, btn) {
btn.disabled = true;
btn.textContent = '⏳ Envoi...';
fetch('/freegames/notify/' + id, { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Erreur: ' + data.message);
btn.disabled = false;
btn.textContent = '📢 Notifier';
}
})
.catch(e => {
alert('Erreur: ' + e);
btn.disabled = false;
btn.textContent = '📢 Notifier';
});
}
function markNotified(id, btn) {
fetch('/freegames/mark-notified/' + id, { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
}
})
.catch(e => alert('Erreur: ' + e));
}
function resetGame(id, btn) {
fetch('/freegames/reset/' + id, { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
}
})
.catch(e => alert('Erreur: ' + e));
}
function deleteGame(id, btn) {
if (!confirm('Supprimer ce jeu de la liste ?')) return;
fetch('/freegames/delete/' + id, { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.success) {
btn.closest('.game-card').remove();
}
})
.catch(e => alert('Erreur: ' + e));
}
</script>
{% endblock %}
+224
View File
@@ -0,0 +1,224 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">FreeLoot — Jeux gratuits</h1>
{% if request.args.get('msg') %}
{% set msg_type = request.args.get('type') %}
<div class="mb-4 p-4 rounded-lg {% if msg_type == 'success' %}bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300{% elif msg_type == 'error' %}bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300{% else %}bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300{% endif %}">
{{ request.args.get('msg') }}
</div>
{% endif %}
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Notifications des jeux gratuits (Epic Games, Amazon Prime, GOG, Google Play, Apple App Store) via le flux
<a href="https://feed.eikowagenknecht.com/lootscraper.xml" target="_blank" rel="noopener" class="text-amber-700 dark:text-amber-300 hover:underline">LootScraper</a>.
Choisissez le canal Discord et les types de loot à notifier (PC, Android, iOS selon la source). Le bot vérifie le flux environ toutes les 30 minutes.
</p>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center gap-3 mb-6">
<span class="text-3xl">🎁</span>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Configuration FreeLoot</h2>
</div>
<form action="{{ url_for('updateFreeLoot') }}" method="POST" class="space-y-6">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="freeloot_enable" {% if configuration.getValue('freeloot_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer les notifications FreeLoot</span>
</label>
</div>
<div>
<label for="freeloot_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal Discord pour les notifications</label>
<select name="freeloot_channel_id" id="freeloot_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-amber-500 focus:border-transparent transition-all">
<option value="">— Choisir un canal —</option>
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('freeloot_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Mentions (optionnel)</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Choisissez qui mentionner au début du message (avant lembed).</p>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="freeloot_mention_everyone" {% if mention_everyone %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">@everyone</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="freeloot_mention_here" {% if mention_here %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">@here</span>
</label>
</div>
{% if roles %}
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôles à mentionner</p>
{% if roles|length > 1 %}
<div class="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-600 mb-3">
{% for guild_data in roles %}
<button type="button" class="freeloot-role-tab px-4 py-2 text-sm font-medium rounded-t-lg transition-colors {% if loop.first %}bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}"
data-tab="freeloot-roles-{{ guild_data.guild_id }}" {% if loop.first %}data-default{% endif %}>
{{ guild_data.guild_name }}
</button>
{% endfor %}
</div>
{% endif %}
{% for guild_data in roles %}
<div id="freeloot-roles-{{ guild_data.guild_id }}" class="freeloot-role-panel {% if not loop.first %}hidden{% endif %} max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-lg p-3 space-y-2">
{% for role in guild_data.roles %}
<label class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600/50 p-1 rounded">
<input type="checkbox" name="freeloot_mention_roles" value="{{ role.id }}"
{% if role.id|string in mention_role_ids %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
{% if role.color is defined and role.color is not none and role.color.value != 0 %}
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color:#{{ '%06x'|format(role.color.value) }}"></span>
{% else %}
<span class="w-3 h-3 rounded-full flex-shrink-0 bg-gray-400"></span>
{% endif %}
<span class="text-sm text-gray-700 dark:text-gray-300">{{ role.name }}</span>
</label>
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Types de loot à notifier</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Cochez les sources et plateformes pour lesquelles vous voulez recevoir une notification.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{% for key, label, emoji in sources %}
<label class="flex items-center gap-2 cursor-pointer p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600/50 transition-colors">
<input type="checkbox" name="freeloot_sources" value="{{ key }}"
{% if not enabled_sources or key in enabled_sources %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
<span class="text-lg" title="{{ label }}">{{ emoji }}</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ label }}</span>
</label>
{% endfor %}
</div>
</div>
<button type="submit"
class="px-6 py-2.5 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer
</button>
</form>
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-3">Aperçu de lembed Discord (style DraftBot)</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Exemple du message envoyé dans le canal.</p>
<div class="inline-block rounded-r-lg overflow-hidden border border-gray-300 dark:border-gray-600 bg-[#2f3136] max-w-lg shadow-lg" style="border-left: 4px solid #E67E22;">
<div class="p-4">
<div class="flex items-start gap-2 mb-2">
<a href="#" class="text-[#00a8fc] hover:underline font-semibold text-base flex-1">Definitely Not Fried Chicken gratuit sur l'Epic Games Store !</a>
<img src="https://store.epicgames.com/favicon.ico" alt="" class="w-10 h-10 rounded flex-shrink-0" title="Logo boutique">
</div>
<p class="text-[#dcddde] text-sm leading-relaxed mb-3">Definitely Not Fried Chicken is a business management sim with a twist! Grow your drugs trade through legitimate fronts, managing both sides of the business. Acquire new "businesses", meet new clientele, develop more potent narcotics…</p>
<div class="text-sm mb-2">
<span class="text-[#b9bbbe]">Prix</span>
<p class="text-[#dcddde]"><strong>Gratuit</strong> • jusqu'au 05/02/2026</p>
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm mb-2">
<div><span class="text-[#b9bbbe]">Prix recommandé</span><p class="text-[#dcddde]">39.99 EUR</p></div>
<div><span class="text-[#b9bbbe]">Genres</span><p class="text-[#dcddde]">Simulation, Indie</p></div>
<div><span class="text-[#b9bbbe]">Ratings</span><p class="text-[#dcddde]">PEGI 18, USK 18</p></div>
</div>
<p class="mb-3"><a href="#" class="text-[#00a8fc] hover:underline text-sm">Ouvrir dans la boutique !</a></p>
<div class="rounded overflow-hidden bg-[#202225] aspect-video flex items-center justify-center my-2">
<span class="text-4xl text-gray-500">🎁</span>
</div>
<p class="text-xs text-[#72767d] pt-1">MamieHenriette • FreeLoot</p>
</div>
</div>
</div>
</div>
<script>
document.querySelectorAll('.freeloot-role-tab').forEach(btn => {
btn.addEventListener('click', function() {
var tabId = this.getAttribute('data-tab');
document.querySelectorAll('.freeloot-role-panel').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('.freeloot-role-tab').forEach(b => {
b.classList.remove('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
b.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
});
document.getElementById(tabId).classList.remove('hidden');
this.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
this.classList.add('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
});
});
document.querySelector('.freeloot-role-tab[data-default]')?.click();
</script>
{% if entries %}
<div class="mt-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Jeux gratuits actuellement disponibles</h2>
</div>
<div class="p-6 overflow-x-auto">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{% for e in entries %}
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md transition-shadow flex flex-col">
<div class="aspect-video bg-gray-100 dark:bg-gray-700 flex items-center justify-center overflow-hidden">
{% if e.image_url %}
<img src="{{ e.image_url }}" alt="" class="w-full h-full object-cover" loading="lazy" />
{% else %}
<span class="text-4xl text-gray-400 dark:text-gray-500">🎁</span>
{% endif %}
</div>
<div class="p-4 flex flex-col flex-1 border-t border-gray-200 dark:border-gray-700">
<h3 class="font-semibold text-gray-900 dark:text-white mb-1 line-clamp-2" title="{{ e.game_name }}">{{ e.game_name }}</h3>
<p class="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 mb-2">
<span>{{ e.emoji }}</span>
<span>{{ e.source_label }}</span>
</p>
{% if e.recommended_price or e.genres or e.rating %}
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-0.5 mb-2">
{% if e.recommended_price %}<p><span class="text-gray-500 dark:text-gray-500">Prix recommandé:</span> {{ e.recommended_price }}</p>{% endif %}
{% if e.genres %}<p><span class="text-gray-500 dark:text-gray-500">Genres:</span> {{ e.genres }}</p>{% endif %}
{% if e.rating %}<p><span class="text-gray-500 dark:text-gray-500">Ratings:</span> {{ e.rating }}</p>{% endif %}
</div>
{% endif %}
{% if e.updated_formatted %}
<p class="text-xs text-gray-500 dark:text-gray-500 mb-3">{{ e.updated_formatted }}</p>
{% endif %}
<div class="mt-auto flex flex-col gap-2">
<form action="{{ url_for('send_free_loot_to_discord') }}" method="POST" class="w-full">
<input type="hidden" name="entry_id" value="{{ e.id }}">
<button type="submit" class="w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
<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-.03z"/></svg>
Envoyer sur Discord
</button>
</form>
{% if e.link %}
<a href="{{ e.link }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center gap-2 w-full px-3 py-2 text-sm font-medium rounded-lg bg-amber-600 hover:bg-amber-700 text-white transition-colors focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Ouvrir dans la boutique
<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>
</a>
{% endif %}
</div>
</div>
</article>
{% endfor %}
</div>
</div>
</div>
</div>
{% else %}
<div class="mt-8 p-4 rounded-lg bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700">
<p class="text-gray-600 dark:text-gray-400 text-sm">Le flux LootScraper na pas pu être chargé. Réessayez plus tard.</p>
</div>
{% endif %}
{% endblock %}
+63 -35
View File
@@ -1,41 +1,69 @@
{% 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>
<div class="info-box">
<h3>📝 Variables disponibles</h3>
<p>Vous pouvez utiliser ces variables dans vos humeurs, elles seront remplacées automatiquement :</p>
<ul>
<li><code>{servers}</code> — Nombre de serveurs gérés par le bot</li>
<li><code>{members}</code> — Nombre total de membres sur tous les serveurs</li>
<li><code>{channels}</code> — Nombre total de salons sur tous les serveurs</li>
</ul>
<p><em>Exemple : "Je surveille {members} personnes sur {servers} serveurs 👀"</em></p>
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Humeurs de Mamie</h1>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Définissez les statuts Discord qui changeront automatiquement toutes les 10 minutes pour donner de la personnalité à votre bot.
</p>
</div>
</div>
<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-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Liste des humeurs</h2>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Texte</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-24">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for humeur in humeurs %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 py-4 text-gray-700 dark:text-gray-300">{{ humeur.text }}</td>
<td class="px-6 py-4 text-center">
<a href="{{ url_for('delHumeur', id = humeur.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette humeur ?')"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400 inline-block"
title="Supprimer">
<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="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>
</td>
</tr>
{% else %}
<tr>
<td colspan="2" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
Aucune humeur configurée. Ajoutez-en une ci-dessous.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</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-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Ajouter une humeur</h2>
<form action="{{ url_for('addHumeur') }}" method="POST" class="space-y-6">
<div>
<label for="text" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Texte du statut</label>
<input name="text" id="text" type="text" required
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="Joue à un jeu vidéo..."/>
</div>
<div>
<button type="submit"
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Ajouter l'humeur
</button>
</div>
</form>
</div>
{% endblock %}
+86 -145
View File
@@ -1,161 +1,102 @@
{% extends "template.html" %}
{% block content %}
<h1>Tableau de bord</h1>
<p>Bienvenue sur l'interface d'administration de Mamie Henriette.</p>
<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>
<!-- Services -->
<section class="dashboard-section">
<h2>🔌 Services</h2>
<div class="stats-grid">
<div class="stat-card {{ 'online' if stats.discord_connected else 'offline' }}">
<div class="stat-icon">🤖</div>
<div class="stat-content">
<span class="stat-label">Discord</span>
<span class="stat-value">{{ 'Connecté' if stats.discord_connected else 'Déconnecté' }}</span>
{% if stats.discord_connected and stats.discord_bot_name %}
<span class="stat-sub">{{ stats.discord_bot_name }}</span>
{% endif %}
</div>
<div class="stat-status {{ 'online' if stats.discord_connected else 'offline' }}"></div>
</div>
<div class="stat-card {{ 'online' if stats.twitch_connected else 'offline' }}">
<div class="stat-icon">📺</div>
<div class="stat-content">
<span class="stat-label">Twitch</span>
<span class="stat-value">{{ 'Connecté' if stats.twitch_connected else 'Déconnecté' }}</span>
{% if stats.twitch_connected and stats.twitch_channel %}
<span class="stat-sub">#{{ stats.twitch_channel }}</span>
{% endif %}
</div>
<div class="stat-status {{ 'online' if stats.twitch_connected else 'offline' }}"></div>
<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>
</section>
<!-- Statistiques Discord -->
{% if stats.discord_connected %}
<section class="dashboard-section">
<h2>📊 Statistiques Discord</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">🏠</div>
<div class="stat-content">
<span class="stat-label">Serveurs</span>
<span class="stat-value big">{{ stats.discord_guilds }}</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="stat-card">
<div class="stat-icon">👥</div>
<div class="stat-content">
<span class="stat-label">Membres</span>
<span class="stat-value big">{{ stats.discord_members }}</span>
</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="stat-card">
<div class="stat-icon">💬</div>
<div class="stat-content">
<span class="stat-label">Salons</span>
<span class="stat-value big">{{ stats.discord_channels }}</span>
<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>
</section>
</div>
<!-- Envoyer une annonce -->
<section class="dashboard-section">
<h2>📢 Envoyer une annonce</h2>
<form id="announce-form" class="announce-form-inline">
<div class="announce-row">
<input type="text" id="message-input" name="message" placeholder="Votre message..." required>
<select id="channel-select" name="channel_id" required>
<option value="">Salon</option>
{% for channel in channels %}
<option value="{{ channel.id }}">#{{ channel.name }} ({{ channel.guild_name }})</option>
{% endfor %}
</select>
<button type="submit" id="send-btn">
<span class="btn-text">📨 Envoyer</span>
<span class="btn-loading" style="display: none;"></span>
</button>
<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>
<div id="announce-result" class="announce-result" style="display: none;"></div>
</form>
</section>
{% endif %}
<!-- Fonctionnalités -->
<section class="dashboard-section">
<h2>⚡ Fonctionnalités</h2>
<div class="cogs-grid">
{% for cog_name, enabled in stats.cogs_enabled.items() %}
<div class="cog-item {{ 'enabled' if enabled else 'disabled' }}">
<span class="cog-status">{{ '✅' if enabled else '❌' }}</span>
<span class="cog-name">{{ cog_name }}</span>
</div>
{% else %}
<p class="text-muted">Aucune information disponible. Le bot Discord n'est peut-être pas encore connecté.</p>
{% endfor %}
<span class="text-sm text-slate-500 dark:text-slate-400">
{% if twitch_connected %}Connecté{% else %}Déconnecté{% endif %}
</span>
</div>
</section>
<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">Annonces configurées</p>
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ twitch_announcements_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">Actions de modération</p>
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ twitch_moderation_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 flex items-center justify-center">
<div class="flex flex-wrap gap-3 justify-center">
<a href="/announcements" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-medium hover:bg-purple-700 transition-colors">Annonces</a>
<a href="/twitch-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>
<a href="/link-filter" 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">Filtre de liens</a>
</div>
</div>
</div>
</div>
{% if stats.last_update %}
<p class="last-update">Dernière mise à jour : {{ stats.last_update.strftime('%d/%m/%Y à %H:%M:%S') }}</p>
{% endif %}
<script>
document.getElementById('announce-form')?.addEventListener('submit', async function(e) {
e.preventDefault();
const btn = document.getElementById('send-btn');
const btnText = btn.querySelector('.btn-text');
const btnLoading = btn.querySelector('.btn-loading');
const result = document.getElementById('announce-result');
const channelId = document.getElementById('channel-select').value;
const message = document.getElementById('message-input').value;
// UI loading
btn.disabled = true;
btnText.style.display = 'none';
btnLoading.style.display = 'inline';
result.style.display = 'none';
try {
const response = await fetch('/api/send-message', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
channel_id: channelId,
message: message
})
});
const data = await response.json();
result.style.display = 'block';
if (data.success) {
result.className = 'announce-result success';
result.textContent = '✅ ' + data.message;
document.getElementById('message-input').value = '';
} else {
result.className = 'announce-result error';
result.textContent = '❌ ' + data.error;
}
} catch (error) {
result.style.display = 'block';
result.className = 'announce-result error';
result.textContent = '❌ Erreur de connexion';
} finally {
btn.disabled = false;
btnText.style.display = 'inline';
btnLoading.style.display = 'none';
}
});
</script>
<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 %}
+176
View File
@@ -0,0 +1,176 @@
{% extends "template.html" %}
{% block content %}
<div class="space-y-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Filtre de liens</h1>
<p class="text-gray-600 dark:text-gray-400">Bloquez les liens non autorises sur votre chat Twitch.</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="p-3 rounded-lg {{ 'bg-green-100 dark:bg-green-900/30' if config.enabled else 'bg-gray-100 dark:bg-gray-700' }}">
<svg class="w-6 h-6 {{ 'text-green-600 dark:text-green-400' if config.enabled else 'text-gray-500' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Protection des liens</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'Active' if config.enabled else 'Desactive' }}</p>
</div>
</div>
<a href="{{ url_for('toggle_link_filter') }}" class="px-4 py-2 rounded-lg font-medium transition-colors {{ 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' if config.enabled else 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' }}">
{{ 'Desactiver' if config.enabled else 'Activer' }}
</a>
</div>
<form action="{{ url_for('update_link_filter') }}" method="POST" class="space-y-6">
<div class="grid md:grid-cols-2 gap-6">
<div class="space-y-4">
<h3 class="font-medium text-gray-900 dark:text-white">Autoriser les liens pour</h3>
<label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input type="checkbox" name="allow_moderators" {{ 'checked' if config.allow_moderators }} class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
<div>
<span class="text-gray-900 dark:text-white font-medium">Moderateurs</span>
<p class="text-xs text-gray-500 dark:text-gray-400">Les modos peuvent toujours poster des liens</p>
</div>
</label>
<label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input type="checkbox" name="allow_vips" {{ 'checked' if config.allow_vips }} class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
<div>
<span class="text-gray-900 dark:text-white font-medium">VIP</span>
<p class="text-xs text-gray-500 dark:text-gray-400">Les VIP peuvent poster des liens</p>
</div>
</label>
<label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input type="checkbox" name="allow_subscribers" {{ 'checked' if config.allow_subscribers }} class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
<div>
<span class="text-gray-900 dark:text-white font-medium">Abonnes</span>
<p class="text-xs text-gray-500 dark:text-gray-400">Les abonnes peuvent poster des liens</p>
</div>
</label>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Duree du timeout (secondes)</label>
<input type="number" name="timeout_duration" value="{{ config.timeout_duration }}" min="0" max="1209600"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">0 = pas de timeout, juste suppression du message</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message d'avertissement</label>
<textarea name="warning_message" rows="2"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none">{{ config.warning_message }}</textarea>
</div>
</div>
</div>
<button type="submit" class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium">
Enregistrer les parametres
</button>
</form>
</div>
<div class="grid md:grid-cols-2 gap-6">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<h3 class="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
</svg>
Domaines autorises ({{ domains|length }})
</h3>
</div>
<form action="{{ url_for('add_allowed_domain') }}" method="POST" class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex gap-2">
<input type="text" name="domain" placeholder="exemple.com" required
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm">
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors text-sm font-medium">
Ajouter
</button>
</div>
</form>
{% if domains %}
<div class="max-h-48 overflow-y-auto">
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{% for domain in domains %}
<div class="px-4 py-2 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
<code class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ domain.domain }}</code>
<a href="{{ url_for('delete_allowed_domain', domain_id=domain.id) }}" class="text-red-600 hover:text-red-700 text-sm">Supprimer</a>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400 text-sm">
Aucun domaine autorise
</div>
{% endif %}
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<h3 class="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
Viewers autorises ({{ users|length }})
</h3>
</div>
<form action="{{ url_for('add_allowed_user') }}" method="POST" class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex gap-2">
<input type="text" name="username" placeholder="@pseudo" required
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm">
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium">
Ajouter
</button>
</div>
</form>
{% if users %}
<div class="max-h-48 overflow-y-auto">
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{% for user in users %}
<div class="px-4 py-2 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
<span class="text-sm text-gray-700 dark:text-gray-300">@{{ user.username }}</span>
<a href="{{ url_for('delete_allowed_user', user_id=user.id) }}" class="text-red-600 hover:text-red-700 text-sm">Supprimer</a>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400 text-sm">
Aucun viewer en liste blanche
</div>
{% endif %}
</div>
</div>
<div class="bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 rounded-xl p-6 border border-blue-200 dark:border-blue-800">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-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>
Commande Permit
</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4">
Utilisez la commande <code class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs">!permit</code> pour autoriser temporairement un viewer a poster un lien.
</p>
<div class="flex flex-wrap gap-4 text-sm">
<div class="flex items-center gap-2">
<code class="px-2 py-1 bg-white dark:bg-gray-800 rounded text-xs font-mono text-purple-600 dark:text-purple-400">!permit @viewer</code>
<span class="text-gray-600 dark:text-gray-400">Autorise 1 min</span>
</div>
<div class="flex items-center gap-2">
<code class="px-2 py-1 bg-white dark:bg-gray-800 rounded text-xs font-mono text-purple-600 dark:text-purple-400">!permit @viewer 5</code>
<span class="text-gray-600 dark:text-gray-400">Autorise 5 min</span>
</div>
</div>
</div>
</div>
{% endblock %}
+327 -68
View File
@@ -1,76 +1,335 @@
{% 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-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Alerte Live</h1>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Liste des chaînes 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 passe de "hors-ligne" à "en ligne" alors
le bot enverra une notification (embed Discord) sur le canal choisi.
<span class="font-medium text-blue-600 dark:text-blue-400">Ne peut surveiller qu'au maximum 100 chaînes.</span>
</p>
</div>
</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="mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Alertes configurées</h2>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Chaîne</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Canal</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message / Embed</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" title="Le bot affichera 'Regarde [streamer]' comme activité">Activité</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for alert in alerts %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<a href="https://www.twitch.tv/{{alert.login}}" target="_blank" class="text-purple-600 dark:text-purple-400 hover:underline font-medium">{{alert.login}}</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-700 dark:text-gray-300">{{alert.notify_channel_name}}</td>
<td class="px-6 py-4 text-gray-600 dark:text-gray-400 max-w-md truncate">{{alert.message or '(embed)'}}</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<a href="{{ url_for('toggleWatchActivity', id = alert.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
title="{{ 'Désactiver l\'activité' if alert.watch_activity else 'Activer l\'activité' }}">
{{ '👁️' if alert.watch_activity else '👁️‍🗨️' }}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
<a href="{{ url_for('toggleLiveAlert', id = alert.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
title="{{ 'Désactiver' if alert.enable else 'Activer' }}">
{{ '✅' if alert.enable else '❌' }}
</a>
<a href="{{ url_for('openEditLiveAlert', id = alert.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors text-blue-600 dark:text-blue-400"
title="Modifier">
<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 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
</a>
<a href="{{ url_for('delLiveAlert', id = alert.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette alerte ?')"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400"
title="Supprimer">
<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="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>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
Aucune alerte configurée. Ajoutez-en une ci-dessous.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</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>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
{{ 'Modifier l\'alerte' if alert else 'Ajouter une alerte de Live' }}
</h2>
{% endblock %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<form id="live-alert-form" action="{{ url_for('submitEditLiveAlert', id = alert.id) if alert else url_for('addLiveAlert') }}" method="POST" class="space-y-6">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Configuration de base</h3>
<div>
<label for="login" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Chaîne Twitch</label>
<input name="login" id="login" type="text" maxlength="32" required
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="chainesteve"
value="{{alert.login if alert}}"/>
</div>
<div>
<label for="notify_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de notification Discord</label>
<select name="notify_channel" id="notify_channel"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-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>
<label for="message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message (optionnel, avant l'embed)</label>
<textarea name="message" id="message" rows="2"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all resize-y"
placeholder="Message envoyé avant l'embed">{{alert.message if alert}}</textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Variables: {user_name}, {title}, [lien](https://www.twitch.tv/{user_login})</p>
</div>
<div class="flex items-center gap-3">
<input type="checkbox" name="watch_activity" id="watch_activity" value="1"
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700"
{% if alert and alert.watch_activity %}checked{% endif %}>
<label for="watch_activity" class="text-sm font-medium text-gray-700 dark:text-gray-300">
Afficher "Regarde ce stream" comme activité du bot Discord
</label>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Personnalisation de l'embed Discord</h3>
<div>
<label for="embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre de l'embed</label>
<input name="embed_title" id="embed_title" type="text" maxlength="256"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="{title}"
value="{{alert.embed_title if alert else '{title}'}}"/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Variables: {title}, {user_name}, {game_name}, {stream_url}, {user_login}</p>
</div>
<div>
<label for="embed_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description de l'embed</label>
<textarea name="embed_description" id="embed_description" rows="2"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all resize-y"
placeholder="Description optionnelle">{{alert.embed_description if alert}}</textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="embed_color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Couleur</label>
<div class="flex items-center gap-2">
<input name="embed_color" id="embed_color" type="color"
class="w-12 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
value="#{{alert.embed_color if alert else '9146FF'}}"/>
<input type="text" id="embed_color_text" maxlength="6"
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
value="{{alert.embed_color if alert else '9146FF'}}" placeholder="9146FF"/>
</div>
</div>
<div>
<label for="embed_author_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom de l'auteur</label>
<input name="embed_author_name" id="embed_author_name" type="text" maxlength="256"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="{user_name}"
value="{{alert.embed_author_name if alert}}"/>
</div>
</div>
<div>
<label for="embed_author_icon" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icône de l'auteur (URL)</label>
<input name="embed_author_icon" id="embed_author_icon" type="text" maxlength="512"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="Laissez vide pour l'avatar Twitch"
value="{{alert.embed_author_icon if alert}}"/>
</div>
<div>
<label for="embed_footer" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Pied de page</label>
<input name="embed_footer" id="embed_footer" type="text" maxlength="2048"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="Texte optionnel en bas"
value="{{alert.embed_footer if alert}}"/>
</div>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="embed_thumbnail" id="embed_thumbnail"
{% if not alert or alert.embed_thumbnail %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Miniature (preview)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="embed_image" id="embed_image"
{% if not alert or alert.embed_image %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Image principale</span>
</label>
</div>
</div>
<div class="flex items-center gap-4">
<button type="submit"
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
{{ 'Enregistrer' if alert else 'Ajouter l\'alerte' }}
</button>
{% if alert %}
<a href="{{ url_for('openLiveAlert') }}"
class="px-6 py-2.5 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 font-medium rounded-lg transition-colors">
Annuler
</a>
{% endif %}
</div>
</form>
</div>
<div>
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-4">Prévisualisation de l'embed Discord</h3>
<div id="embed-preview" class="bg-[#2f3136] rounded p-4 font-sans text-[#dcddde] max-w-xl border-l-4" style="border-left-color: #9146FF;">
<div id="embed-author" class="flex items-center mb-2 text-sm">
<img id="embed-author-icon" src="https://static-cdn.jtvnw.net/ttv-favicon/favicon-32x32.png" class="w-5 h-5 rounded-full mr-2" onerror="this.style.display='none'"/>
<span id="embed-author-name" class="font-semibold">Nom du streamer</span>
</div>
<a id="embed-title" href="#" class="text-[#00aff4] no-underline text-base font-semibold block mb-2">Titre du stream</a>
<div id="embed-description" class="text-sm leading-relaxed mb-2 text-[#dcddde]"></div>
<div id="embed-thumbnail-container" class="my-2">
<img id="embed-thumbnail" src="" class="max-w-[80px] max-h-[80px] rounded float-right ml-4 hidden"/>
</div>
<div id="embed-image-container" class="mt-4">
<img id="embed-image" src="https://static-cdn.jtvnw.net/previews-ttv/live_user_chaine-320x180.jpg" class="max-w-full rounded hidden"/>
</div>
<div id="embed-footer" class="mt-2 text-xs text-[#72767d]"></div>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Cette prévisualisation est approximative.</p>
<div class="mt-6 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 class="font-medium text-gray-800 dark:text-gray-200 mb-2">Variables disponibles (embed)</h4>
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{user_login}</code> — Login Twitch</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{user_name}</code> — Nom d'affichage</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{game_name}</code> — Jeu en cours</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{title}</code> — Titre du stream</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{language}</code> — Langue</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{stream_url}</code> — Lien Twitch</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{thumbnail}</code> — URL preview</li>
</ul>
</div>
</div>
</div>
</div>
<script>
function formatText(text, vars) {
if (!text) return '';
return text.replace(/\{(\w+)\}/g, function(match, key) {
return vars[key] !== undefined && vars[key] !== null ? vars[key] : match;
});
}
function updatePreview() {
const embedTitle = document.getElementById('embed_title').value || '{title}';
const embedDescription = document.getElementById('embed_description').value || '';
const embedColor = document.getElementById('embed_color_text').value || '9146FF';
const embedAuthorName = document.getElementById('embed_author_name').value || '{user_name}';
const embedAuthorIcon = document.getElementById('embed_author_icon').value || '';
const embedFooter = document.getElementById('embed_footer').value || '';
const embedThumbnail = document.getElementById('embed_thumbnail').checked;
const embedImage = document.getElementById('embed_image').checked;
const vars = {
user_login: 'chainesteve',
user_name: 'ChaîneSteve',
game_name: 'Minecraft',
title: '🔴 Live chill avec les viewers',
language: 'fr',
stream_url: 'https://www.twitch.tv/chainesteve',
thumbnail: 'https://static-cdn.jtvnw.net/previews-ttv/live_user_chainesteve-320x180.jpg'
};
document.getElementById('embed-title').textContent = formatText(embedTitle, vars);
document.getElementById('embed-title').href = vars.stream_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 || 'https://static-cdn.jtvnw.net/ttv-favicon/favicon-32x32.png';
document.getElementById('embed-author-icon').style.display = embedAuthorIcon ? '' : 'none';
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';
}
}
const colorInput = document.getElementById('embed_color');
if (colorInput) {
colorInput.addEventListener('input', function(e) {
document.getElementById('embed_color_text').value = e.target.value.substring(1).toUpperCase();
updatePreview();
});
}
const colorText = document.getElementById('embed_color_text');
if (colorText) {
colorText.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(function(field) {
const el = document.getElementById(field);
if (el) {
el.addEventListener('input', updatePreview);
el.addEventListener('change', updatePreview);
}
});
updatePreview();
</script>
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends "template.html" %}
{% block content %}
<div class="max-w-md mx-auto py-12">
<h1 class="text-2xl font-bold text-slate-800 dark:text-white mb-6 text-center">Connexion</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-4 space-y-2">
{% for category, msg in messages %}
<p class="p-3 rounded-lg text-sm {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200{% endif %}">{{ msg }}</p>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('login') }}" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 space-y-4">
<div>
<label for="identifier" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Identifiant (nom d'utilisateur ou e-mail)</label>
<input type="text" id="identifier" name="identifier" required autocomplete="username" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="nom ou email">
</div>
<div>
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Mot de passe</label>
<input type="password" id="password" name="password" required autocomplete="current-password" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent">
</div>
<button type="submit" class="w-full py-2.5 px-4 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">Se connecter</button>
</form>
{% if registration_enabled %}
<p class="mt-4 text-center text-sm text-slate-600 dark:text-slate-400">
Pas encore de compte ? <a href="{{ url_for('register') }}" class="text-primary-600 dark:text-primary-400 hover:underline">Créer un compte</a>
</p>
{% endif %}
</div>
{% endblock %}
+193 -339
View File
@@ -1,369 +1,223 @@
{% extends "template.html" %}
{% block content %}
<h1>Modération Discord</h1>
<p>Historique des actions de modération effectuées sur le serveur Discord. Le bot enregistre automatiquement les avertissements, exclusions et bannissements.</p>
<!-- Navigation entre les sections -->
<div class="moderation-nav">
<a href="{{ url_for('moderation') }}" class="nav-btn {{ '' if show_invites else 'active' }}">📋 Événements de modération</a>
<a href="{{ url_for('moderation_invitations') }}" class="nav-btn {{ 'active' if show_invites else '' }}">🔗 Invitations</a>
<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>
<!-- Section Statistiques -->
<div class="dashboard-section">
<h2>📊 Statistiques</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">📋</div>
<div class="stat-content">
<span class="stat-label">Total des sanctions</span>
<span class="stat-value big">{{ stats.total }}</span>
</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="stat-card">
<div class="stat-icon"></div>
<div class="stat-content">
<span class="stat-label">Dernières 24h</span>
<span class="stat-value big">{{ stats.recent_24h }}</span>
<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 class="stat-card">
<div class="stat-icon">📅</div>
<div class="stat-content">
<span class="stat-label">7 derniers jours</span>
<span class="stat-value big">{{ stats.recent_7d }}</span>
</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="stat-card">
<div class="stat-icon">📆</div>
<div class="stat-content">
<span class="stat-label">30 derniers jours</span>
<span class="stat-value big">{{ stats.recent_30d }}</span>
<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>
<!-- Section Types de sanctions et Top modérateurs -->
<div class="moderation-insights">
<div class="insight-card">
<h3>🛡️ Top Modérateurs</h3>
{% if stats.top_moderators %}
<div class="leaderboard">
{% for mod_name, count in stats.top_moderators %}
<div class="leaderboard-item">
<span class="rank">{{ loop.index }}</span>
<span class="name">{{ mod_name }}</span>
<span class="count">{{ count }} sanction{{ 's' if count > 1 else '' }}</span>
<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 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">!transfert #canal message_id</code>
<span class="text-slate-500 dark:text-slate-400">Transfère un message vers un autre canal (textuel, thread ou forum)</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted">Aucune donnée disponible</p>
{% endif %}
</div>
<div class="insight-card">
<h3>⚖️ Types de sanctions</h3>
{% if stats.type_counts %}
<div class="type-badges">
{% for type_name, count in stats.type_counts.items() %}
<div class="type-badge {{ type_name | lower }}">
<span class="type-name">{{ type_name }}</span>
<span class="type-count">{{ count }}</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted">Aucune donnée disponible</p>
{% endif %}
</div>
<div class="insight-card">
<h3>⚠️ Utilisateurs les plus sanctionnés</h3>
{% if stats.top_sanctioned %}
<div class="leaderboard">
{% for username, count in stats.top_sanctioned %}
<div class="leaderboard-item warning">
<span class="rank">{{ loop.index }}</span>
<span class="name">{{ username }}</span>
<span class="count">{{ count }} sanction{{ 's' if count > 1 else '' }}</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted">Aucune donnée disponible</p>
{% endif %}
</div>
</details>
</div>
<!-- Section Commandes (collapsible) -->
<details class="commands-section">
<summary>
<span class="summary-icon">📖</span>
<span>Commandes de modération disponibles</span>
</summary>
<div class="commands-grid">
<div class="command-card">
<div class="command-header">
<code>!averto @utilisateur raison</code>
</div>
<p>Avertit un utilisateur et enregistre l'avertissement</p>
<small>Alias : !warn, !av, !avertissement</small>
</div>
<div class="command-card">
<div class="command-header">
<code>!delaverto id</code>
</div>
<p>Retire un avertissement par son ID</p>
<small>Alias : !removewarn, !delwarn</small>
</div>
<div class="command-card">
<div class="command-header">
<code>!warnings [@utilisateur]</code>
</div>
<p>Liste les événements de modération</p>
<small>Alias : !listevent, !listwarn</small>
</div>
<div class="command-card">
<div class="command-header">
<code>!inspect @utilisateur</code>
</div>
<p>Informations détaillées sur un utilisateur</p>
<small>Aussi : !inspect id</small>
</div>
<div class="command-card">
<div class="command-header">
<code>!kick @utilisateur raison</code>
</div>
<p>Expulse un utilisateur du serveur</p>
</div>
<div class="command-card">
<div class="command-header">
<code>!ban @utilisateur raison</code>
</div>
<p>Bannit définitivement un utilisateur</p>
</div>
<div class="command-card">
<div class="command-header">
<code>!unban discord_id</code>
</div>
<p>Révoque un bannissement</p>
<small>Aussi : !unban #sanction_id raison</small>
</div>
<div class="command-card">
<div class="command-header">
<code>!banlist</code>
</div>
<p>Liste des utilisateurs bannis</p>
</div>
<div class="command-card">
<div class="command-header">
<code>!aide</code>
</div>
<p>Affiche toutes les commandes</p>
<small>Alias : !help</small>
</div>
</div>
</details>
{% if not event %}
<h2>📜 Historique des événements</h2>
{% if events %}
<table class="moderation">
<thead>
<tr>
<th>Type</th>
<th>Utilisateur</th>
<th>Discord ID</th>
<th>Date & Heure</th>
<th>Raison</th>
<th>Staff</th>
<th>#</th>
</tr>
</thead>
<tbody>
{% for mod_event in events %}
<tr>
<td><span class="event-type {{ mod_event.type | lower if mod_event.type else '' }}">{{ mod_event.type }}</span></td>
<td>{{ mod_event.username }}</td>
<td><code>{{ mod_event.discord_id }}</code></td>
<td>{{ mod_event.created_at.strftime('%d/%m/%Y %H:%M') if mod_event.created_at else 'N/A' }}</td>
<td>{{ mod_event.reason }}</td>
<td>{{ mod_event.staff_name }}</td>
<td>
<a href="{{ url_for('open_edit_moderation_event', event_id = mod_event.id) }}" class="icon" title="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="icon" title="Supprimer">🗑</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<div class="empty-icon">🎉</div>
<p>Aucun événement de modération enregistré</p>
<small>Le serveur est sage !</small>
<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>
{% elif mod_event.type == 'transfer' %}
<span class="text-xs font-medium text-blue-600 dark:text-blue-400">Transfert</span>
{% elif mod_event.type == 'timeout' %}
<span class="text-xs font-medium text-purple-600 dark:text-purple-400">Timeout</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 %}
{% endif %}
{% if event %}
<h2>✏️ Editer un événement</h2>
<form action="{{ url_for('update_moderation_event', event_id = event.id) }}" method="POST">
<label for="type">Type</label>
<input name="type" type="text" value="{{ event.type }}" disabled />
<label for="username">Utilisateur</label>
<input name="username" type="text" value="{{ event.username }}" disabled />
<label for="discord_id">Discord ID</label>
<input name="discord_id" type="text" value="{{ event.discord_id }}" disabled />
<label for="reason">Raison</label>
<input name="reason" type="text" value="{{ event.reason }}" required="required" />
<label for="staff_name">Staff</label>
<input name="staff_name" type="text" value="{{ event.staff_name }}" disabled />
<input type="Submit" value="Modifier">
<a href="{{ url_for('moderation') }}">Annuler</a>
</form>
{% endif %}
{% if show_invites %}
<!-- Section Invitations Discord -->
<h2>🔗 Invitations Discord</h2>
<div class="invites-actions">
<a href="{{ url_for('sync_invitations') }}" class="btn btn-primary">🔄 Synchroniser les invitations</a>
<label class="checkbox-label">
<input type="checkbox" id="show-revoked" {{ 'checked' if show_revoked else '' }} onchange="toggleRevoked()">
Afficher les invitations révoquées
</label>
</div>
{% if invite_stats %}
<!-- Statistiques des invitations -->
<div class="dashboard-section">
<h3>📊 Statistiques des invitations</h3>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">🔗</div>
<div class="stat-content">
<span class="stat-label">Invitations actives</span>
<span class="stat-value big">{{ invite_stats.total }}</span>
<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 class="stat-card">
<div class="stat-icon">👥</div>
<div class="stat-content">
<span class="stat-label">Utilisations totales</span>
<span class="stat-value big">{{ invite_stats.total_uses }}</span>
</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="stat-card">
<div class="stat-icon">♾️</div>
<div class="stat-content">
<span class="stat-label">Permanentes</span>
<span class="stat-value big">{{ invite_stats.permanent }}</span>
</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>
<div class="stat-card">
<div class="stat-icon"></div>
<div class="stat-content">
<span class="stat-label">Temporaires</span>
<span class="stat-value big">{{ invite_stats.temporary }}</span>
</div>
</div>
</div>
</div>
{% if invite_stats.top_inviters %}
<div class="moderation-insights">
<div class="insight-card">
<h3>🏆 Top inviteurs</h3>
<div class="leaderboard">
{% for inviter_name, uses in invite_stats.top_inviters %}
<div class="leaderboard-item">
<span class="rank">{{ loop.index }}</span>
<span class="name">{{ inviter_name }}</span>
<span class="count">{{ uses }} membre{{ 's' if uses > 1 else '' }} invité{{ 's' if uses > 1 else '' }}</span>
</div>
{% endfor %}
</div>
</div>
</form>
</div>
{% endif %}
{% endif %}
<!-- Liste des invitations -->
{% if invites %}
<table class="moderation invites-table">
<thead>
<tr>
<th>Code</th>
<th>Canal</th>
<th>Créateur</th>
<th>Utilisations</th>
<th>Max utilisations</th>
<th>Expiration</th>
<th>Créée le</th>
<th>Statut</th>
<th>#</th>
</tr>
</thead>
<tbody>
{% for invite in invites %}
<tr class="{{ 'revoked' if invite.revoked else '' }} {{ 'expired' if invite.is_expired else '' }}">
<td><code>{{ invite.code }}</code></td>
<td>{{ invite.channel_name or 'N/A' }}</td>
<td>{{ invite.inviter_name or 'Inconnu' }}</td>
<td>{{ invite.uses or 0 }}</td>
<td>{{ invite.max_uses if invite.max_uses > 0 else '∞' }}</td>
<td>
{% if invite.max_age == 0 %}
<span class="badge permanent">Jamais</span>
{% elif invite.expires_at %}
{{ invite.expires_at.strftime('%d/%m/%Y %H:%M') }}
{% else %}
N/A
{% endif %}
</td>
<td>{{ invite.created_at.strftime('%d/%m/%Y %H:%M') if invite.created_at else 'N/A' }}</td>
<td>
{% if invite.revoked %}
<span class="badge revoked">Révoquée</span>
{% elif invite.is_expired %}
<span class="badge expired">Expirée</span>
{% elif invite.max_uses > 0 and invite.uses >= invite.max_uses %}
<span class="badge used">Max atteint</span>
{% else %}
<span class="badge active">Active</span>
{% endif %}
</td>
<td>
{% if not invite.revoked %}
<a href="{{ url_for('revoke_invitation', invite_code=invite.code) }}"
onclick="return confirm('Êtes-vous sûr de vouloir révoquer l\'invitation {{ invite.code }} ?')"
class="icon" title="Révoquer">🗑</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<div class="empty-icon">🔗</div>
<p>Aucune invitation trouvée</p>
<small>Cliquez sur "Synchroniser les invitations" pour récupérer les invitations depuis Discord</small>
</div>
{% endif %}
<script>
function toggleRevoked() {
const showRevoked = document.getElementById('show-revoked').checked;
window.location.href = '{{ url_for("moderation_invitations") }}?show_revoked=' + showRevoked;
}
</script>
{% endif %}
{% endblock %}
+127 -53
View File
@@ -1,61 +1,135 @@
{% 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-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">ProtonDB</h1>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
ProtonDB évalue la compatibilité des jeux Windows sur Linux via Steam Play.
</p>
</div>
</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="mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Alias de jeux</h2>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Alias</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Jeu</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-24">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for a in aliases %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<code class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-purple-600 dark:text-purple-400 font-mono">{{ a.alias }}</code>
</td>
<td class="px-6 py-4 text-gray-700 dark:text-gray-300">{{ a.name }}</td>
<td class="px-6 py-4 text-center">
<a href="{{ url_for('delGameAlias', id = a.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet alias ?')"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400 inline-block"
title="Supprimer">
<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="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>
</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
Aucun alias configuré. Ajoutez-en un ci-dessous.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</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-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">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-6">
<div>
<label for="alias" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Alias</label>
<input name="alias" id="alias" type="text" maxlength="32" required
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="GTA"/>
</div>
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom du jeu</label>
<input name="name" id="name" type="text" maxlength="256" required
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="Grand Theft Auto"/>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Si vous créez un alias <code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">GTA</code><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">Grand Theft Auto</code>,
alors la commande <code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">!protondb GTA 5</code> fera une recherche sur <strong class="text-gray-800 dark:text-gray-200">Grand Theft Auto 5</strong>.
</p>
</div>
<div>
<button type="submit"
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Ajouter l'alias
</button>
</div>
</form>
</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>
{% endblock %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Configuration</h2>
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-6">
<div class="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<input type="checkbox" name="proton_db_enable_enable" id="proton_db_enable_enable"
{% if configuration.getValue('proton_db_enable_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<label for="proton_db_enable_enable" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
Activer la commande ProtonDB
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="proton_db_api_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API ID</label>
<input name="proton_db_api_id" id="proton_db_api_id" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('proton_db_api_id') }}"/>
</div>
<div>
<label for="proton_db_api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Clé API</label>
<input name="proton_db_api_key" id="proton_db_api_key" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('proton_db_api_key') }}"/>
</div>
</div>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<p class="text-sm text-amber-800 dark:text-amber-200">
Pour trouver les clés : ouvrez l'outil d'inspection (F12) dans votre navigateur, 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" class="underline hover:no-underline">Voir l'exemple</a>
</p>
</div>
<div>
<button type="submit"
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer la configuration
</button>
</div>
</form>
</div>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
{% extends "template.html" %}
{% block content %}
<div class="max-w-md mx-auto py-12">
<h1 class="text-2xl font-bold text-slate-800 dark:text-white mb-6 text-center">Créer un compte</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-4 space-y-2">
{% for category, msg in messages %}
<p class="p-3 rounded-lg text-sm {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200{% endif %}">{{ msg }}</p>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('register') }}" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Nom d'utilisateur</label>
<input type="text" id="username" name="username" required minlength="3" autocomplete="username" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="min. 3 caractères">
</div>
<div>
<label for="email" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Adresse e-mail</label>
<input type="email" id="email" name="email" required autocomplete="email" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="vous@exemple.com">
</div>
<div>
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Mot de passe</label>
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="min. 8 caractères">
</div>
<div>
<label for="password_confirm" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Confirmer le mot de passe</label>
<input type="password" id="password_confirm" name="password_confirm" required minlength="8" autocomplete="new-password" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent">
</div>
<button type="submit" class="w-full py-2.5 px-4 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">S'inscrire</button>
</form>
<p class="mt-4 text-center text-sm text-slate-600 dark:text-slate-400">
Déjà un compte ? <a href="{{ url_for('login') }}" class="text-primary-600 dark:text-primary-400 hover:underline">Se connecter</a>
</p>
</div>
{% endblock %}
+386
View File
@@ -0,0 +1,386 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Paramètres et Permissions</h1>
<p class="text-gray-600 dark:text-gray-400">Configuration des rôles et des accès aux pages (super administrateur uniquement)</p>
</div>
<button type="button" onclick="document.getElementById('help-modal').classList.remove('hidden')"
class="px-4 py-2 rounded-lg bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 hover:bg-primary-200 dark:hover:bg-primary-900/50 transition-colors flex items-center gap-2">
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Aide</span>
</button>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-6 space-y-2">
{% for category, msg in messages %}
<div class="p-4 rounded-lg {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800{% endif %}">
{{ msg }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="space-y-8">
<!-- Inscriptions -->
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<div class="flex-grow">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Inscriptions</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Autoriser ou bloquer la création de nouveaux comptes par les visiteurs</p>
<form action="{{ url_for('settings_toggle_registration') }}" method="post">
<label class="inline-flex items-center gap-3 cursor-pointer group">
<div class="relative">
<input type="checkbox" name="enabled" value="1" {% if registration_enabled %}checked{% endif %}
onchange="this.form.submit()"
class="sr-only peer">
<div class="w-14 h-7 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-green-500 dark:peer-checked:bg-green-600 transition-colors"></div>
<div class="absolute left-1 top-1 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-7 shadow-md"></div>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white">
{% if registration_enabled %}✓ Inscriptions activées{% else %}✗ Inscriptions désactivées{% endif %}
</span>
</label>
</form>
</div>
</div>
</section>
<!-- Rôles -->
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div class="flex-grow">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Hiérarchie des rôles</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
Les rôles définissent le niveau d'accès des utilisateurs. Plus le niveau est élevé, plus les permissions sont étendues.
</p>
</div>
</div>
</div>
<div class="p-6">
<div class="space-y-3 mb-6">
{% for r in roles %}
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 transition-colors">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" style="background-color: {{ r.color or '#6B7280' }}20;">
<span class="text-2xl" style="color: {{ r.color or '#6B7280' }};"></span>
</div>
<div class="flex-grow min-w-0">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ r.name }}</h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
style="background-color: {{ r.color or '#6B7280' }}20; color: {{ r.color or '#6B7280' }};">
Niveau {{ r.level }}
</span>
</div>
{% if r.description %}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ r.description }}</p>
{% else %}
{% set default_desc = default_roles_meta.get(r.name, {}).get('description') %}
{% if default_desc %}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ default_desc }}</p>
{% endif %}
{% endif %}
<details class="group">
<summary class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 cursor-pointer list-none flex items-center gap-1">
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span>Modifier ce rôle</span>
</summary>
<form action="{{ url_for('settings_role_edit', role_id=r.id) }}" method="post" class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Niveau (0-99)</label>
<input type="number" name="level" value="{{ r.level }}" min="0" max="99"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Couleur</label>
<input type="color" name="color" value="{{ r.color or '#6B7280' }}"
class="w-full h-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 cursor-pointer">
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
<input type="text" name="description" value="{{ r.description or '' }}" placeholder="Description du rôle..."
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div class="sm:col-span-2 flex items-center gap-2">
<button type="submit" class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white text-sm font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
Enregistrer
</button>
{% if r.name not in ['viewer_twitch','utilisateur_discord','moderateur_discord','expert_discord','moderateur_twitch','super_administrateur'] %}
<button type="button" onclick="if(confirm('Supprimer ce rôle ?')) { this.closest('details').querySelector('form').setAttribute('action', '{{ url_for('settings_role_delete', role_id=r.id) }}'); this.closest('form').submit(); }"
class="px-4 py-2 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm font-medium hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors">
Supprimer
</button>
{% endif %}
</div>
</form>
</details>
</div>
</div>
</div>
{% endfor %}
</div>
<details class="bg-primary-50 dark:bg-primary-900/20 rounded-lg border border-primary-200 dark:border-primary-800">
<summary class="px-4 py-3 cursor-pointer list-none flex items-center gap-2 text-primary-700 dark:text-primary-300 font-medium hover:text-primary-800 dark:hover:text-primary-200">
<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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span>Créer un nouveau rôle</span>
</summary>
<form action="{{ url_for('settings_role_add') }}" method="post" class="p-4 pt-0 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Nom du rôle *</label>
<input type="text" name="name" placeholder="ex: editeur_contenu" required minlength="2"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Niveau (0-99) *</label>
<input type="number" name="level" value="0" min="0" max="99"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Couleur</label>
<input type="color" name="color" value="#6B7280"
class="w-full h-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 cursor-pointer">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Icône (optionnel)</label>
<input type="text" name="icon" placeholder="ex: star, shield, user"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
<input type="text" name="description" placeholder="Description du rôle..."
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div class="sm:col-span-2">
<button type="submit" class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
Créer le rôle
</button>
</div>
</form>
</details>
</div>
</section>
<!-- Permissions par page -->
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div class="flex-grow">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Accès aux pages</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
Définissez le rôle minimum requis pour accéder à chaque section de l'interface
</p>
</div>
</div>
</div>
<!-- Appliquer en masse -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/30">
<details class="group">
<summary class="cursor-pointer list-none flex items-center gap-2 text-gray-700 dark:text-gray-300 font-medium hover:text-gray-900 dark:hover:text-white">
<svg class="w-5 h-5 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span>⚡ Modification en masse</span>
</summary>
<form action="{{ url_for('settings_permissions_bulk') }}" method="post" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
<div class="flex flex-wrap items-center gap-4 mb-4">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<span>Appliquer le rôle :</span>
<select name="role" required class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
{% for r in roles %}
<option value="{{ r.name }}" style="color: {{ r.color or '#6B7280' }};">{{ r.name }} (niveau {{ r.level }})</option>
{% endfor %}
</select>
</label>
<span class="text-sm text-gray-500 dark:text-gray-400">aux pages cochées :</span>
</div>
<div class="flex flex-wrap gap-2 mb-4">
<button type="button" onclick="document.querySelectorAll('.bulk-page-cb').forEach(c => c.checked = true)"
class="text-xs px-3 py-1.5 rounded-lg bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
Tout cocher
</button>
<button type="button" onclick="document.querySelectorAll('.bulk-page-cb').forEach(c => c.checked = false)"
class="text-xs px-3 py-1.5 rounded-lg bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
Tout décocher
</button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 mb-4">
{% for page_key, meta in page_metadata.items() %}
<label class="flex items-center gap-2 px-3 py-2 rounded-lg bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:border-primary-300 dark:hover:border-primary-600 cursor-pointer transition-colors">
<input type="checkbox" name="page_keys" value="{{ page_key }}" class="bulk-page-cb rounded border-gray-300 dark:border-gray-600 text-primary-600 focus:ring-primary-500">
<span class="text-xs text-gray-700 dark:text-gray-300">{{ meta.label }}</span>
</label>
{% endfor %}
</div>
<button type="submit" class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
Appliquer à la sélection
</button>
</form>
</details>
</div>
<!-- Pages par catégorie -->
<div class="p-6">
{% for category_key in ['general', 'content', 'moderation', 'config', 'admin'] %}
{% if category_key in pages_by_category %}
{% set category_info = category_labels[category_key] %}
<div class="mb-8 last:mb-0">
<div class="flex items-center gap-3 mb-4">
<div class="w-8 h-8 rounded-lg flex items-center justify-center" style="background-color: {{ category_info.color }}20;">
<span class="text-lg" style="color: {{ category_info.color }};"></span>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ category_info.label }}</h3>
<div class="flex-grow h-px bg-gray-200 dark:bg-gray-700"></div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for page_data in pages_by_category[category_key] %}
{% set page_key = page_data.key %}
{% set meta = page_data.meta %}
{% set perm = page_data.permission %}
{% set min_lvl = perm.min_level if perm else 0 %}
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 transition-colors">
<div class="flex items-start justify-between gap-3 mb-2">
<div>
<h4 class="font-medium text-gray-900 dark:text-white mb-1">{{ meta.label }}</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">{{ meta.description }}</p>
</div>
{% for r in roles %}
{% if r.level == min_lvl %}
<span class="flex-shrink-0 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap"
style="background-color: {{ r.color or '#6B7280' }}20; color: {{ r.color or '#6B7280' }};">
{{ r.name }}
</span>
{% endif %}
{% endfor %}
</div>
<form action="{{ url_for('settings_permissions_update') }}" method="post" class="flex items-center gap-2">
<input type="hidden" name="page_key" value="{{ page_key }}">
<select name="role" class="flex-grow px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-xs focus:ring-2 focus:ring-primary-500">
{% for r in roles %}
<option value="{{ r.name }}" {% if r.level == min_lvl %}selected{% endif %}>{{ r.name }} (niv. {{ r.level }})</option>
{% endfor %}
</select>
<button type="submit" class="px-3 py-1.5 rounded-lg bg-primary-600 dark:bg-primary-500 text-white text-xs font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
OK
</button>
</form>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
</section>
</div>
<!-- Modal d'aide -->
<div id="help-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" onclick="document.getElementById('help-modal').classList.add('hidden')"></div>
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
<div class="bg-white dark:bg-gray-800 px-6 pt-6 pb-4">
<div class="flex items-start justify-between mb-4">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Guide des permissions</h3>
<button type="button" onclick="document.getElementById('help-modal').classList.add('hidden')"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-4 text-sm text-gray-600 dark:text-gray-400">
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">🎭 Rôles</h4>
<p>Les rôles définissent le niveau d'autorité d'un utilisateur. Plus le niveau est élevé, plus l'utilisateur a accès à des fonctionnalités.</p>
<ul class="list-disc list-inside mt-2 space-y-1 ml-4">
<li><strong>Niveau 0-1</strong> : Accès basique en lecture seule</li>
<li><strong>Niveau 2-3</strong> : Modification de contenu et gestion basique</li>
<li><strong>Niveau 4</strong> : Modération et configuration avancée</li>
<li><strong>Niveau 5+</strong> : Administration complète du système</li>
</ul>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">🔐 Permissions par page</h4>
<p>Chaque page peut être restreinte à un rôle minimum. Un utilisateur doit avoir au moins le niveau requis pour y accéder.</p>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">📋 Catégories</h4>
<ul class="list-disc list-inside space-y-1 ml-4">
<li><strong style="color: #6B7280;"></strong> <strong>Général</strong> : Pages d'accueil et navigation</li>
<li><strong style="color: #3B82F6;"></strong> <strong>Contenu</strong> : Gestion des commandes, alertes, humeurs, etc.</li>
<li><strong style="color: #EF4444;"></strong> <strong>Modération</strong> : Outils de modération Discord/Twitch</li>
<li><strong style="color: #8B5CF6;"></strong> <strong>Configuration</strong> : Paramètres techniques des bots</li>
<li><strong style="color: #F59E0B;"></strong> <strong>Administration</strong> : Gestion des utilisateurs et permissions</li>
</ul>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">💡 Bonnes pratiques</h4>
<ul class="list-disc list-inside space-y-1 ml-4">
<li>Attribuez le rôle le plus bas possible selon les besoins</li>
<li>Testez les permissions avec un compte utilisateur avant de les déployer</li>
<li>Documentez les rôles personnalisés avec des descriptions claires</li>
<li>Vérifiez régulièrement les accès des utilisateurs</li>
</ul>
</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4">
<button type="button" onclick="document.getElementById('help-modal').classList.add('hidden')"
class="w-full px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
Compris !
</button>
</div>
</div>
</div>
</div>
<style>
/* Améliorer le style du toggle switch */
input[type="checkbox"].sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
</style>
{% endblock %}
+321 -70
View File
@@ -1,85 +1,336 @@
<!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="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<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="/" class="nav-logo">
<img src="/static/ico/favicon.ico" alt="Mamie Henriette">
<span>Mamie Henriette</span>
</a>
<input type="checkbox" id="nav-toggle" class="nav-toggle">
<label for="nav-toggle" class="nav-toggle-label">
<span></span>
<span></span>
<span></span>
</label>
<ul class="nav-menu">
<li class="has-submenu">
<a href="#">Discord</a>
<ul class="submenu">
<li><a href="/commandes">📝 Commandes</a></li>
<li><a href="/humeurs">😊 Humeurs</a></li>
<li><a href="/moderation">🛡️ Modération</a></li>
</ul>
</li>
<li class="has-submenu">
<a href="#">Twitch</a>
<ul class="submenu">
<li><a href="/live-alert">📺 Alerte Live</a></li>
</ul>
</li>
<li class="has-submenu">
<a href="#">YouTube</a>
<ul class="submenu">
<li><a href="/youtube-alert">🎬 Alertes Vidéos</a></li>
</ul>
</li>
<li class="has-submenu">
<a href="#">Outils</a>
<ul class="submenu">
<li><a href="/freegames">🎮 Jeux Gratuits</a></li>
<li><a href="/protondb">🐧 ProtonDB</a></li>
</ul>
</li>
<li>
<a href="/configurations">⚙️ Config</a>
</li>
</ul>
</nav>
</header>
<main>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash-message {{ category }}">{{ message }}</div>
{% endfor %}
<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="{{ url_for('open_twitch_events') }}" 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 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
Événements Twitch (sub, raid, clip)
</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>
<a href="{{ url_for('openFreeLoot') }}" 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">
<span class="text-lg">🎁</span>
FreeLoot
</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>
<a href="/configurations#auto-rooms" 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="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg>
Auto Rooms
</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>
<a href="{{ url_for('open_twitch_events') }}" 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 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
Événements (sub, raid, clip)
</a>
<a href="/announcements" 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 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path></svg>
Annonces
</a>
<a href="/twitch-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>
Moderation
</a>
<a href="/link-filter" 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="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
Filtre de liens
</a>
</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>
{% if current_user.is_authenticated and current_user_level >= 5 %}
<a href="{{ url_for('users_list') }}" 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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
Utilisateurs
</a>
<a href="{{ url_for('settings') }}" 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>
Paramètres
</a>
{% endif %}
</div>
<!-- Actions -->
<div class="flex items-center gap-2">
{% if current_user.is_authenticated %}
<span class="hidden sm:inline text-sm text-gray-600 dark:text-gray-400" title="Rôle : {{ current_user.role }}">{{ current_user.username }}</span>
<a href="{{ url_for('logout') }}" class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Déconnexion</a>
{% else %}
<a href="{{ url_for('login') }}" class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Connexion</a>
{% if registration_enabled %}
<a href="{{ url_for('register') }}" class="px-3 py-2 rounded-lg text-sm font-medium bg-primary-600 dark:bg-primary-500 text-white hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">Créer un compte</a>
{% endif %}
{% endif %}
<!-- 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>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</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="/announcements" 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 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path></svg>
Annonces Twitch
</a>
<a href="/twitch-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>
Moderation Twitch
</a>
<a href="/link-filter" 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="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
Filtre de liens
</a>
<a href="{{ url_for('open_twitch_events') }}" 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 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
Événements Twitch (sub, raid, clip)
</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="/configurations#auto-rooms" 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="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg>
Auto Rooms
</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="{{ url_for('openFreeLoot') }}" 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">
<span class="text-lg">🎁</span>
FreeLoot
</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>
{% if current_user.is_authenticated and current_user_level >= 5 %}
<a href="{{ url_for('users_list') }}" 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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
Utilisateurs
</a>
<a href="{{ url_for('settings') }}" 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">
Paramètres
</a>
{% endif %}
{% if current_user.is_authenticated %}
<a href="{{ url_for('logout') }}" 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 border-t border-gray-200 dark:border-gray-700 mt-2 pt-4">
Déconnexion ({{ current_user.username }})
</a>
{% else %}
<a href="{{ url_for('login') }}" 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 border-t border-gray-200 dark:border-gray-700 mt-2 pt-4">Connexion</a>
{% if registration_enabled %}
<a href="{{ url_for('register') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-primary-600 dark:text-primary-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">Créer un compte</a>
{% endif %}
{% endif %}
</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>
+84 -27
View File
@@ -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 %}
+117
View File
@@ -0,0 +1,117 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Notifications d'événements Twitch</h1>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Configurez les notifications pour les abonnements, follows, raids et nouveaux clips.
Pour chaque type d'événement vous pouvez activer l'envoi dans le <strong>chat Twitch</strong> et/ou dans un <strong>canal Discord</strong>.
Les événements sub, follow et raid utilisent Twitch EventSub ; les clips sont détectés par vérification périodique.
</p>
</div>
</div>
<form action="{{ url_for('save_twitch_events') }}" method="POST" class="space-y-8">
{% for cfg in configs %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-wrap gap-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ labels[cfg.event_type] }}</h2>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="hidden" name="ev_{{ cfg.event_type }}_enable" value="0">
<input type="checkbox" name="ev_{{ cfg.event_type }}_enable" value="1" {{ 'checked' if cfg.enable }}
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer</span>
</label>
<a href="{{ url_for('toggle_twitch_event', event_type=cfg.event_type) }}"
class="text-sm {{ 'text-green-600 dark:text-green-400' if cfg.enable else 'text-gray-500' }}">
{{ 'Activé' if cfg.enable else 'Désactivé' }}
</a>
</div>
</div>
<div class="p-6 space-y-6">
<div class="grid md:grid-cols-2 gap-6">
<div class="space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Où notifier</h3>
<label class="flex items-center gap-3 cursor-pointer">
<input type="hidden" name="ev_{{ cfg.event_type }}_notify_twitch_chat" value="0">
<input type="checkbox" name="ev_{{ cfg.event_type }}_notify_twitch_chat" value="1" {{ 'checked' if cfg.notify_twitch_chat }}
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
<span class="text-gray-700 dark:text-gray-300">Chat Twitch</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input type="hidden" name="ev_{{ cfg.event_type }}_notify_discord" value="0">
<input type="checkbox" name="ev_{{ cfg.event_type }}_notify_discord" value="1" {{ 'checked' if cfg.notify_discord }}
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
<span class="text-gray-700 dark:text-gray-300">Discord (canal de notifs)</span>
</label>
<div>
<label for="ev_{{ cfg.event_type }}_discord_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Canal Discord</label>
<select name="ev_{{ cfg.event_type }}_discord_channel_id" id="ev_{{ cfg.event_type }}_discord_channel"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">— Choisir un canal —</option>
{% for ch in channels %}
<option value="{{ ch.id }}" {{ 'selected' if cfg.discord_channel_id == ch.id }}>{{ ch.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="space-y-4">
<div>
<label for="ev_{{ cfg.event_type }}_message_twitch" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message (chat Twitch)</label>
<input type="text" name="ev_{{ cfg.event_type }}_message_twitch" id="ev_{{ cfg.event_type }}_message_twitch" maxlength="500"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
value="{{ cfg.message_twitch }}" placeholder="Merci {user} !">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Sub/Follow: <code>{user}</code> <code>{user_name}</code>
Raid: <code>{from_broadcaster_name}</code> <code>{viewers}</code>
Clip: <code>{user}</code> <code>{title}</code> <code>{url}</code>
</p>
</div>
<div>
<label for="ev_{{ cfg.event_type }}_message_discord" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message Discord (optionnel, avant l'embed)</label>
<textarea name="ev_{{ cfg.event_type }}_message_discord" id="ev_{{ cfg.event_type }}_message_discord" rows="2" maxlength="2000"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-y">{{ cfg.message_discord or '' }}</textarea>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<h4 class="font-medium text-gray-800 dark:text-gray-200 mb-3">Embed Discord (optionnel)</h4>
<div class="grid sm:grid-cols-2 gap-4">
<div>
<label for="ev_{{ cfg.event_type }}_embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titre</label>
<input type="text" name="ev_{{ cfg.event_type }}_embed_title" id="ev_{{ cfg.event_type }}_embed_title" maxlength="256"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
value="{{ cfg.embed_title or '' }}" placeholder="Ex: Nouveau clip">
</div>
<div>
<label for="ev_{{ cfg.event_type }}_embed_color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Couleur (hex)</label>
<input type="text" name="ev_{{ cfg.event_type }}_embed_color" id="ev_{{ cfg.event_type }}_embed_color" maxlength="6"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono"
value="{{ cfg.embed_color or '9146FF' }}" placeholder="9146FF">
</div>
</div>
<div class="mt-3">
<label for="ev_{{ cfg.event_type }}_embed_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
<textarea name="ev_{{ cfg.event_type }}_embed_description" id="ev_{{ cfg.event_type }}_embed_description" rows="2" maxlength="2000"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-y">{{ cfg.embed_description or '' }}</textarea>
</div>
<label class="flex items-center gap-2 mt-3 cursor-pointer">
<input type="hidden" name="ev_{{ cfg.event_type }}_embed_thumbnail" value="0">
<input type="checkbox" name="ev_{{ cfg.event_type }}_embed_thumbnail" value="1" {{ 'checked' if cfg.embed_thumbnail }}
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
<span class="text-sm text-gray-700 dark:text-gray-300">Miniature dans l'embed (clips)</span>
</label>
</div>
</div>
</div>
{% endfor %}
<div class="flex justify-end">
<button type="submit" class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer tout
</button>
</div>
</form>
{% endblock %}
+964
View File
@@ -0,0 +1,964 @@
{% extends "template.html" %}
{% block content %}
<style>
.panel-moderation {
display: grid;
grid-template-columns: minmax(320px, 56%) 1fr;
gap: 1rem;
min-height: calc(100vh - 8rem);
}
@media (max-width: 1024px) {
.panel-moderation { grid-template-columns: 1fr; }
}
.player-wrap {
position: relative;
width: 100%;
padding-bottom: 56.25%;
height: 0;
overflow: hidden;
border-radius: 12px;
background: #0e0e10;
box-shadow: 0 4px 20px rgba(0,0,0,.25);
}
.player-wrap iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
.chat-wrap {
border-radius: 12px;
overflow: hidden;
background: #18181b;
box-shadow: 0 4px 20px rgba(0,0,0,.2);
min-height: 320px;
display: flex;
flex-direction: column;
}
.chat-wrap iframe {
flex: 1;
min-height: 280px;
border: none;
}
.mod-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: #1f1f23;
border-bottom: 1px solid #2d2d35;
}
.mod-toolbar input[type="text"] {
width: 140px;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid #3d3d48;
background: #18181b;
color: #efeff1;
font-size: 13px;
}
.mod-toolbar input::placeholder { color: #6b6b7b; }
.mod-toolbar button {
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
border: none;
cursor: pointer;
transition: background .15s, transform .05s;
}
.mod-toolbar button:active { transform: scale(0.98); }
.btn-timeout { background: #e0a82e; color: #1f1f23; }
.btn-timeout:hover { background: #e8b84a; }
.btn-ban { background: #eb0400; color: #fff; }
.btn-ban:hover { background: #ff1a15; }
.btn-unban { background: #338847; color: #fff; }
.btn-unban:hover { background: #3da352; }
.btn-clean { background: #53535f; color: #efeff1; }
.btn-clean:hover { background: #63636f; }
.btn-copy { background: #9147ff; color: #fff; }
.btn-copy:hover { background: #a970ff; }
.right-col { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
.commandes-logs {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
min-height: 0;
}
.section-card {
background: #fff;
border-radius: 12px;
border: 1px solid #e5e7eb;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,.06);
}
.dark .section-card {
background: #1f2937;
border-color: #374151;
box-shadow: 0 1px 3px rgba(0,0,0,.2);
}
.section-card-header {
padding: 10px 14px;
font-weight: 600;
font-size: 14px;
color: #111827;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.dark .section-card-header {
background: #111827;
color: #f3f4f6;
border-color: #374151;
}
.section-card-body {
max-height: 220px;
overflow: auto;
}
.section-card-body-logs {
max-height: 280px;
}
.cmd-table, .logs-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.cmd-table th, .logs-table th {
text-align: left;
padding: 8px 12px;
background: #f3f4f6;
color: #4b5563;
font-weight: 600;
}
.dark .cmd-table th, .dark .logs-table th { background: #374151; color: #9ca3af; }
.cmd-table td, .logs-table td { padding: 6px 12px; border-bottom: 1px solid #e5e7eb; color: #374151; }
.dark .cmd-table td, .dark .logs-table td { border-color: #374151; color: #e5e7eb; }
.cmd-table tr:hover, .logs-table tr:hover { background: #f9fafb; }
.dark .cmd-table tr:hover, .dark .logs-table tr:hover { background: #374151; }
.page-title-mod { font-size: 1.25rem; margin-bottom: 0.5rem; }
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
</style>
<div class="space-y-4">
<div>
<h1 class="page-title-mod font-bold text-gray-900 dark:text-white">Modération Twitch</h1>
<p class="text-sm text-gray-600 dark:text-gray-400">Commandes et logs de modération pour {{ twitch_channel }}</p>
</div>
<!-- Info bricks: Live status, Link filter, Banned words -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Live Status -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg {{ 'bg-red-100 dark:bg-red-900/30' if is_live else 'bg-gray-100 dark:bg-gray-700' }}">
<svg class="w-5 h-5 {{ 'text-red-600 dark:text-red-400' if is_live else 'text-gray-500' }}" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="10" r="8"/>
</svg>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white text-sm">Live</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ 'En ligne' if is_live else 'Hors ligne' }}</p>
</div>
</div>
{% if is_live %}
<div class="text-right">
<div class="text-lg font-bold text-purple-600 dark:text-purple-400">{{ viewer_count }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">viewers</div>
</div>
{% endif %}
</div>
</div>
<!-- Link Filter -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg {{ 'bg-green-100 dark:bg-green-900/30' if link_filter_enabled else 'bg-gray-100 dark:bg-gray-700' }}">
<svg class="w-5 h-5 {{ 'text-green-600 dark:text-green-400' if link_filter_enabled else 'text-gray-500' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white text-sm">Filtre de liens</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ 'Actif' if link_filter_enabled else 'Inactif' }}</p>
</div>
</div>
<a href="{{ url_for('link_filter') }}" class="text-xs text-purple-600 dark:text-purple-400 hover:underline">Configurer</a>
</div>
</div>
<!-- Banned Words -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg {{ 'bg-orange-100 dark:bg-orange-900/30' if banned_words else 'bg-gray-100 dark:bg-gray-700' }}">
<svg class="w-5 h-5 {{ 'text-orange-600 dark:text-orange-400' if banned_words else 'text-gray-500' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
</svg>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white text-sm">Mots interdits</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ banned_words|length }} mot{{ 's' if banned_words|length > 1 else '' }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Commandes & logs Twitch (sans lecteur/iframe) -->
<div class="grid md:grid-cols-2 gap-4">
<!-- Commandes de modération -->
<div class="section-card">
<div class="section-card-header flex items-center justify-between">
<span>Commandes de modération</span>
<input type="text" id="searchInput" placeholder="Rechercher..." class="w-36 px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
</div>
<div class="section-card-body">
<table class="cmd-table" id="commandsTable">
<thead>
<tr>
<th>Commande(s)</th>
<th>Usage</th>
<th>Permission</th>
</tr>
</thead>
<tbody id="commandsBody">
{% for cmd in commands %}
<tr class="command-row" data-search="{{ cmd.commands|join(' ') }} {{ cmd.usage }} {{ cmd.description }}">
<td>
{% for c in cmd.commands %}
<code class="px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded text-xs font-mono">{{ c }}</code>
{% if not loop.last %} {% endif %}
{% endfor %}
</td>
<td><code class="text-xs font-mono text-gray-600 dark:text-gray-400">{{ cmd.usage }}</code></td>
<td><span class="text-xs text-gray-500 dark:text-gray-400">{{ cmd.permission }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Logs de modération -->
<div class="section-card">
<div class="section-card-header flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<span>Logs de modération (<span id="logsCount">{{ logs|length }}</span>)</span>
<div class="flex items-center gap-2">
<input type="text" id="logsSearchInput" placeholder="Rechercher..." class="w-32 px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
{% if logs %}
<a href="{{ url_for('clear_twitch_logs') }}" onclick="return confirm('Effacer tous les logs ?')" class="text-xs text-red-600 hover:text-red-700">Effacer</a>
{% endif %}
</div>
</div>
<div class="section-card-body">
{% if logs %}
<table class="logs-table" id="logsTable">
<thead>
<tr>
<th>Date</th>
<th>Action</th>
<th>Modo</th>
<th>Cible</th>
<th>Détails</th>
</tr>
</thead>
<tbody id="logsBody">
{% for log in logs %}
<tr class="log-row" data-search="{{ log.action }} {{ log.moderator }} {{ log.target or '' }} {{ log.details or '' }}">
<td class="whitespace-nowrap text-gray-500 dark:text-gray-400">{{ log.created_at.strftime('%d/%m %H:%M') }}</td>
<td><code class="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs">{{ log.action }}</code></td>
<td>{{ log.moderator }}</td>
<td>{{ log.target or '-' }}</td>
<td class="max-w-[120px] truncate" title="{{ log.details or '' }}">{{ log.details or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400">Aucun log</div>
{% endif %}
</div>
</div>
</div>
<!-- Lecteur Live + Chat Twitch (si live en cours) OU Formulaire + Chat (hors live) -->
<div class="mt-6">
{% if is_live %}
<!-- Layout avec lecteur vidéo (live en cours) -->
<div class="grid lg:grid-cols-3 gap-6">
<!-- Lecteur Twitch (2/3) -->
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="aspect-video bg-gray-900">
<iframe
src="https://player.twitch.tv/?channel={{ twitch_channel }}&enableExtensions=true&muted=false&parent={{ embed_parent }}&player=popout&quality=auto&volume=0.5"
style="width: 100%; height: 100%; border: none;"
allowfullscreen>
</iframe>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="flex h-2 w-2 relative">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
<span class="text-sm font-medium text-gray-900 dark:text-white">EN DIRECT</span>
<span class="text-sm text-gray-500 dark:text-gray-400">• {{ viewer_count }} viewers</span>
</div>
<a href="https://www.twitch.tv/{{ twitch_channel }}" target="_blank" class="text-xs text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1">
<svg class="w-3 h-3" 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>
Ouvrir sur Twitch
</a>
</div>
</div>
<!-- Formulaire rapide (1/3) -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<h2 class="text-sm font-semibold text-gray-900 dark:text-white mb-3 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="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Nouvelle commande
</h2>
<form action="{{ url_for('add_twitch_commande') }}" method="POST" class="space-y-3">
<input type="text" name="trigger" placeholder="!commande" required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
<select name="twitch_permission"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
{% for value, label in twitch_permissions.items() %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
<textarea name="response" rows="3" placeholder="Réponse... ({user} = mention)" required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm resize-none"></textarea>
<button type="submit" class="w-full px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium text-sm">Ajouter</button>
</form>
</div>
</div>
<!-- Chat en dessous du lecteur -->
<div class="mt-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col" style="height: 500px;">
{% else %}
<!-- Layout sans lecteur (hors live) -->
<div class="grid md:grid-cols-2 gap-6">
<!-- Formulaire d'ajout de commande -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Ajouter une commande
</h2>
<form action="{{ url_for('add_twitch_commande') }}" method="POST" class="space-y-3">
<div class="grid grid-cols-2 gap-2">
<input type="text" name="trigger" placeholder="!commande" required
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
<select name="twitch_permission"
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
{% for value, label in twitch_permissions.items() %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<textarea name="response" rows="2" placeholder="Réponse du bot... ({user} = mention)" required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm resize-none"></textarea>
<button type="submit" class="w-full px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium text-sm">Ajouter</button>
</form>
</div>
<!-- Chat -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col" style="height: 500px;">
{% endif %}
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center justify-between">
<div class="flex items-center gap-2">
<h3 class="font-semibold text-gray-900 dark:text-white">Chat en direct</h3>
<span class="flex h-2 w-2 relative">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
</div>
<a href="https://www.twitch.tv/{{ twitch_channel }}/chat" target="_blank" class="text-xs text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1">
<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>
Ouvrir sur Twitch
</a>
</div>
<!-- Zone d'affichage des messages et actions -->
<div class="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900/50 p-4" id="chatDisplay">
<div class="space-y-2">
<div class="text-center text-sm text-gray-500 dark:text-gray-400 py-8">
<div class="animate-pulse mb-4">
<svg class="w-12 h-12 mx-auto mb-3 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
</svg>
</div>
<p class="font-medium text-gray-700 dark:text-gray-300">Récupération du chat...</p>
<p class="text-xs mt-1">Les messages du chat Twitch apparaîtront ici en temps réel</p>
</div>
</div>
</div>
<!-- Zone de saisie et boutons -->
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 border-t border-gray-200 dark:border-gray-600">
<!-- Boutons d'action rapide -->
<div class="grid grid-cols-4 gap-1.5 mb-3">
<!-- Ligne 1 : Actions sur les users -->
<button onclick="executeQuickAction('ban')" class="px-2 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Bannir un utilisateur">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 75.636 5.636m12.728 12.728L5.636 5.636"></path>
</svg>
Ban
</button>
<button onclick="executeQuickAction('timeout')" class="px-2 py-1.5 bg-orange-600 hover:bg-orange-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Timeout un utilisateur">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Timeout
</button>
<button onclick="executeQuickAction('clean')" class="px-2 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Nettoyer le chat">
<svg class="w-3.5 h-3.5" 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>
Clean
</button>
<button onclick="executeQuickAction('permit')" class="px-2 py-1.5 bg-teal-600 hover:bg-teal-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Permettre un lien">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
Permit
</button>
<!-- Ligne 2 : Modes du chat -->
<button onclick="executeQuickAction('subon')" class="px-2 py-1.5 bg-purple-600 hover:bg-purple-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Activer mode abonnés">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
Sub ON
</button>
<button onclick="executeQuickAction('suboff')" class="px-2 py-1.5 bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Désactiver mode abonnés">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"></path>
</svg>
Sub OFF
</button>
<button onclick="executeQuickAction('emoteon')" class="px-2 py-1.5 bg-yellow-600 hover:bg-yellow-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Activer mode emotes">
😀 ON
</button>
<button onclick="executeQuickAction('emoteoff')" class="px-2 py-1.5 bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Désactiver mode emotes">
😀 OFF
</button>
</div>
<!-- Input de message -->
<form onsubmit="sendMessage(event)" class="flex gap-2">
<input
type="text"
id="chatMessage"
placeholder="Message ou commande..."
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm"
autocomplete="off">
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium text-sm">
Envoyer
</button>
</form>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Envoyé via le bot • <a href="https://www.twitch.tv/popout/{{ twitch_channel }}/chat" target="_blank" class="text-purple-600 dark:text-purple-400 hover:underline">Ouvrir le chat</a></p>
</div>
</div>
</div>
</div> <!-- Fermeture du conteneur principal -->
{% if custom_commands %}
<div class="mt-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<h3 class="font-semibold text-gray-900 dark:text-white">Commandes personnalisées Twitch ({{ custom_commands|length }})</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Commande</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Réponse</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Permission</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for cmd in custom_commands %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-2"><code class="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded text-xs">{{ cmd.trigger }}</code></td>
<td class="px-4 py-2 text-gray-700 dark:text-gray-300 max-w-xs truncate">{{ cmd.response }}</td>
<td class="px-4 py-2">{{ twitch_permissions.get(cmd.twitch_permission or 'viewer', 'Tous') }}</td>
<td class="px-4 py-2"><a href="{{ url_for('delete_commande', commande_id=cmd.id) }}" onclick="return confirm('Supprimer ?')" class="text-red-600 hover:text-red-700 text-xs">Supprimer</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- Section mots interdits -->
<div class="mt-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<h3 class="font-semibold text-gray-900 dark:text-white">Mots interdits ({{ banned_words|length }})</h3>
</div>
<!-- Formulaire d'ajout -->
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<form action="{{ url_for('add_banned_word') }}" method="POST" class="flex flex-wrap gap-2 items-end">
<div class="flex-1 min-w-[200px]">
<label for="banned_word" class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Mot interdit</label>
<input type="text" name="word" id="banned_word" placeholder="mot" required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
</div>
<div class="w-32">
<label for="banned_word_timeout" class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Timeout (s)</label>
<input type="number" name="timeout_duration" id="banned_word_timeout" value="60" min="0" max="1209600"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
</div>
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg transition-colors font-medium text-sm">Ajouter</button>
</form>
</div>
<!-- Liste des mots interdits -->
{% if banned_words %}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Mot</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Timeout</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Ajouté le</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for word in banned_words %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-2"><code class="px-2 py-1 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded text-xs font-mono">{{ word.word }}</code></td>
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">{{ word.timeout_duration }}s</td>
<td class="px-4 py-2 text-gray-500 dark:text-gray-400 text-xs">{{ word.created_at.strftime('%d/%m/%Y %H:%M') if word.created_at else '-' }}</td>
<td class="px-4 py-2"><a href="{{ url_for('delete_banned_word', word_id=word.id) }}" onclick="return confirm('Supprimer ce mot interdit ?')" class="text-red-600 hover:text-red-700 text-xs">Supprimer</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400">Aucun mot interdit configuré</div>
{% endif %}
</div>
</div>
<script>
(function() {
var modTarget = document.getElementById('modTarget');
var copyCmdBtn = document.getElementById('copyCmdBtn');
var lastCmd = '';
document.querySelectorAll('.mod-toolbar button[data-cmd]').forEach(function(btn) {
btn.addEventListener('click', function() {
var user = (modTarget && modTarget.value.trim()) || '';
lastCmd = this.getAttribute('data-cmd').replace(/\{\}/g, user).trim();
if (user && (this.classList.contains('btn-clean') && lastCmd === '!clean')) {
lastCmd = '!clean';
}
navigator.clipboard && navigator.clipboard.writeText(lastCmd).then(function() {
var t = copyCmdBtn.textContent;
copyCmdBtn.textContent = 'Copié !';
setTimeout(function() { copyCmdBtn.textContent = t; }, 1200);
});
});
});
if (copyCmdBtn) {
copyCmdBtn.addEventListener('click', function() {
if (lastCmd) {
navigator.clipboard && navigator.clipboard.writeText(lastCmd).then(function() {
var t = copyCmdBtn.textContent;
copyCmdBtn.textContent = 'Copié !';
setTimeout(function() { copyCmdBtn.textContent = t; }, 1200);
});
}
});
}
var rows = document.querySelectorAll('.command-row');
var searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.addEventListener('input', function() {
var q = this.value.toLowerCase();
rows.forEach(function(row) {
row.style.display = row.dataset.search.toLowerCase().indexOf(q) >= 0 ? '' : 'none';
});
});
}
var logRows = document.querySelectorAll('.log-row');
var logsSearchInput = document.getElementById('logsSearchInput');
var logsCount = document.getElementById('logsCount');
if (logsSearchInput) {
logsSearchInput.addEventListener('input', function() {
var q = this.value.toLowerCase();
var n = 0;
logRows.forEach(function(row) {
var ok = row.dataset.search.toLowerCase().indexOf(q) >= 0;
row.style.display = ok ? '' : 'none';
if (ok) n++;
});
if (logsCount) logsCount.textContent = n;
});
}
})();
// Fonctions pour le chat Twitch
var displayedMessages = new Set();
var isAutoScroll = true;
function addMessageToDisplay(username, message, timestamp, badges, userColor) {
var chatDisplay = document.getElementById('chatDisplay');
var messagesContainer = chatDisplay.querySelector('.space-y-2');
// Supprimer le message d'aide si c'est le premier message
var helpText = messagesContainer.querySelector('.text-center');
if (helpText) {
helpText.remove();
}
// Créer un ID unique pour ce message
var messageId = timestamp + username + message;
if (displayedMessages.has(messageId)) {
return; // Message déjà affiché
}
displayedMessages.add(messageId);
// Créer l'élément de message
var messageDiv = document.createElement('div');
var msgDate = new Date(timestamp);
var timeStr = msgDate.getHours().toString().padStart(2, '0') + ':' + msgDate.getMinutes().toString().padStart(2, '0');
// Déterminer les badges
var badgeIcons = '';
if (badges && badges.is_mod) {
badgeIcons += '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400" title="Modérateur">MOD</span>';
}
if (badges && badges.is_vip) {
badgeIcons += '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-400" title="VIP">VIP</span>';
}
if (badges && badges.is_subscriber) {
badgeIcons += '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400" title="Abonné">SUB</span>';
}
messageDiv.className = 'bg-white dark:bg-gray-800 rounded-lg p-3 text-sm animate-fade-in border-l-2 border-gray-300 dark:border-gray-600';
messageDiv.style.borderLeftColor = userColor || '#9146FF';
messageDiv.innerHTML = `
<div class="flex items-start gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<span class="font-bold" style="color: ${userColor || '#9146FF'}">${escapeHtml(username)}</span>
${badgeIcons}
<span class="text-xs text-gray-500 dark:text-gray-400">${timeStr}</span>
</div>
<div class="text-gray-900 dark:text-gray-100 break-words">${escapeHtml(message)}</div>
</div>
</div>
`;
messagesContainer.appendChild(messageDiv);
// Limiter à 100 messages affichés
while (messagesContainer.children.length > 100) {
messagesContainer.removeChild(messagesContainer.firstChild);
}
// Scroll vers le bas si auto-scroll activé
if (isAutoScroll) {
chatDisplay.scrollTop = chatDisplay.scrollHeight;
}
}
function addBotMessageToDisplay(message, type) {
var chatDisplay = document.getElementById('chatDisplay');
var messagesContainer = chatDisplay.querySelector('.space-y-2');
// Supprimer le message d'aide si c'est le premier message
var helpText = messagesContainer.querySelector('.text-center');
if (helpText) {
helpText.remove();
}
var now = new Date();
var timeStr = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0');
var bgColor = 'bg-blue-50 dark:bg-blue-900/20';
var borderColor = '#3b82f6';
if (type === 'command') {
bgColor = 'bg-purple-50 dark:bg-purple-900/20';
borderColor = '#9146FF';
}
var messageDiv = document.createElement('div');
messageDiv.className = `${bgColor} rounded-lg p-3 text-sm animate-fade-in border-l-2`;
messageDiv.style.borderLeftColor = borderColor;
messageDiv.innerHTML = `
<div class="flex items-start gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-bold text-purple-600 dark:text-purple-400">Bot</span>
<span class="text-xs text-gray-500 dark:text-gray-400">${timeStr}</span>
</div>
<div class="text-gray-900 dark:text-gray-100 break-words">${escapeHtml(message)}</div>
</div>
</div>
`;
messagesContainer.appendChild(messageDiv);
// Limiter à 100 messages
while (messagesContainer.children.length > 100) {
messagesContainer.removeChild(messagesContainer.firstChild);
}
// Scroll vers le bas
if (isAutoScroll) {
chatDisplay.scrollTop = chatDisplay.scrollHeight;
}
}
// Polling pour récupérer les messages
function fetchChatMessages() {
fetch('{{ url_for("get_twitch_messages") }}')
.then(response => response.json())
.then(data => {
if (data.messages && data.messages.length > 0) {
data.messages.forEach(function(msg) {
addMessageToDisplay(
msg.username,
msg.text,
msg.timestamp,
{
is_mod: msg.is_mod,
is_vip: msg.is_vip,
is_subscriber: msg.is_subscriber
},
msg.color
);
});
}
})
.catch(error => {
console.error('Erreur lors de la récupération des messages:', error);
});
}
// Démarrer le polling
setInterval(fetchChatMessages, 2000); // Toutes les 2 secondes
fetchChatMessages(); // Premier chargement immédiat
// Détecter le scroll manuel pour désactiver l'auto-scroll
document.getElementById('chatDisplay').addEventListener('scroll', function() {
var element = this;
var isAtBottom = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
isAutoScroll = isAtBottom;
});
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function sendMessage(event) {
event.preventDefault();
var input = document.getElementById('chatMessage');
var message = input.value.trim();
if (!message) return;
// Désactiver le bouton pendant l'envoi
var submitBtn = event.target.querySelector('button[type="submit"]');
var originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = 'Envoi...';
// Utiliser la fonction sendTextMessage pour les messages normaux
fetch('{{ url_for("send_twitch_message") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: message })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Ajouter le message à l'affichage
var type = message.startsWith('!') ? 'command' : 'message';
addBotMessageToDisplay(message, type);
input.value = '';
showNotification('Message envoyé', 'success');
} else {
showNotification(data.error || 'Erreur lors de l\'envoi', 'error');
}
})
.catch(error => {
showNotification('Erreur réseau', 'error');
})
.finally(function() {
submitBtn.disabled = false;
submitBtn.textContent = originalText;
});
}
function executeQuickAction(action) {
// Commandes simples sans paramètres
if (['subon', 'suboff', 'emoteon', 'emoteoff'].includes(action)) {
executeModerationAction(action, {});
return;
}
// Ban : demander utilisateur + raison
if (action === 'ban') {
var username = prompt('Nom d\'utilisateur à bannir :', '@');
if (username && username.trim() && username !== '@') {
username = username.trim().replace('@', '');
var reason = prompt('Raison (optionnelle) :', 'Violation des règles');
executeModerationAction('ban', {
username: username,
reason: reason || 'Ban'
});
}
return;
}
// Timeout : demander utilisateur + durée + raison
if (action === 'timeout') {
var username = prompt('Nom d\'utilisateur à timeout :', '@');
if (username && username.trim() && username !== '@') {
username = username.trim().replace('@', '');
var duration = prompt('Durée en minutes (défaut: 10) :', '10');
duration = parseInt(duration) || 10;
var reason = prompt('Raison (optionnelle) :', 'Timeout');
executeModerationAction('timeout', {
username: username,
duration: duration * 60, // Convertir en secondes
reason: reason || 'Timeout'
});
}
return;
}
// Clean : demander si utilisateur spécifique ou tout le chat
if (action === 'clean') {
var username = prompt('Nom d\'utilisateur (ou laissez vide pour tout le chat) :', '');
if (username === null) return; // Annulé
executeModerationAction('clean', {
username: username ? username.trim().replace('@', '') : ''
});
return;
}
// Permit : demander utilisateur + durée
if (action === 'permit') {
var username = prompt('Nom d\'utilisateur à autoriser :', '@');
if (username && username.trim() && username !== '@') {
username = username.trim().replace('@', '');
var duration = prompt('Durée en minutes (défaut: 1) :', '1');
duration = parseInt(duration) || 1;
executeModerationAction('permit', {
username: username,
duration: duration * 60 // Convertir en secondes
});
}
return;
}
}
function executeModerationAction(action, params) {
fetch('{{ url_for("execute_moderation_action") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ action: action, params: params })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(data.message || 'Action exécutée', 'success');
// Ajouter un message visuel dans le chat
var actionText = action.toUpperCase();
if (params.username) {
actionText += ' @' + params.username;
}
if (params.duration) {
actionText += ' (' + (params.duration / 60) + 'min)';
}
if (params.reason) {
actionText += ' - ' + params.reason;
}
addBotMessageToDisplay(actionText, 'command');
} else {
showNotification(data.error || 'Erreur lors de l\'exécution', 'error');
}
})
.catch(error => {
showNotification('Erreur réseau', 'error');
});
}
// Cette fonction est conservée pour les messages texte normaux (non-modération)
function sendTextMessage(message) {
fetch('{{ url_for("send_twitch_message") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: message })
})
.then(response => response.json())
.then(data => {
if (data.success) {
var type = message.startsWith('!') ? 'command' : 'message';
addBotMessageToDisplay(message, type);
showNotification('Message envoyé', 'success');
} else {
showNotification(data.error || 'Erreur lors de l\'envoi', 'error');
}
})
.catch(error => {
showNotification('Erreur réseau', 'error');
});
}
function showNotification(message, type) {
var color = type === 'success' ? 'bg-green-500' : 'bg-red-500';
var notification = document.createElement('div');
notification.className = `fixed bottom-4 right-4 ${color} text-white px-4 py-2 rounded-lg shadow-lg z-50`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(function() {
notification.remove();
}, 3000);
}
</script>
{% endblock %}
+292
View File
@@ -0,0 +1,292 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-12 h-12 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg class="w-7 h-7 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Gestion des utilisateurs</h1>
<p class="text-gray-600 dark:text-gray-400">Liste des comptes webapp et attribution des rôles</p>
</div>
</div>
<button type="button" onclick="document.getElementById('create-user-modal').classList.remove('hidden')"
class="flex-shrink-0 px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors flex items-center gap-2">
<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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span>Créer un utilisateur</span>
</button>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-6 space-y-2">
{% for category, msg in messages %}
<div class="p-4 rounded-lg {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800{% endif %}">
{{ msg }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
{% if users %}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Utilisateur
</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
E-mail
</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Rôle actuel
</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Inscrit le
</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Modifier le rôle
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for u in users %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white font-semibold">
{{ u.username[0].upper() }}
</div>
<div>
<div class="font-medium text-gray-900 dark:text-white">{{ u.username }}</div>
{% if u.id == current_user.id %}
<span class="text-xs text-primary-600 dark:text-primary-400 font-medium">C'est vous</span>
{% endif %}
</div>
</div>
</td>
<td class="px-6 py-4">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ u.email }}</span>
</td>
<td class="px-6 py-4">
{% set user_role = None %}
{% for r in roles %}
{% if r == u.role %}
{% set user_role = r %}
{% endif %}
{% endfor %}
{% if user_role %}
{% set role_obj = namespace(found=None) %}
{% for role_name in roles %}
{% if role_name == user_role %}
{% for r_obj in roles %}
{# Obtenir l'objet WebappRole complet #}
{% endfor %}
{% endif %}
{% endfor %}
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap"
style="background-color: #6B728020; color: #6B7280;">
<span class="w-2 h-2 rounded-full" style="background-color: #6B7280;"></span>
{{ role_labels.get(u.role, u.role) }}
</span>
{% else %}
<span class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
{{ role_labels.get(u.role, u.role) }}
</span>
{% endif %}
</td>
<td class="px-6 py-4">
<span class="text-sm text-gray-500 dark:text-gray-400">
{% if u.created_at %}{{ u.created_at.strftime('%d/%m/%Y à %H:%M') }}{% else %}—{% endif %}
</span>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<form action="{{ url_for('users_set_role', user_id=u.id) }}" method="post" class="flex items-center gap-2">
<select name="role" class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 transition-colors"
{% if u.id == current_user.id %}disabled title="Vous ne pouvez pas modifier votre propre rôle"{% endif %}>
{% for r in roles %}
<option value="{{ r }}" {% if u.role == r %}selected{% endif %}>{{ role_labels.get(r, r) }}</option>
{% endfor %}
</select>
<button type="submit"
{% if u.id == current_user.id %}disabled{% endif %}
class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white text-sm font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
Appliquer
</button>
</form>
{% if u.id != current_user.id %}
<form action="{{ url_for('users_delete', user_id=u.id) }}" method="post" class="inline" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ?');">
<button type="submit" class="px-3 py-2 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm font-medium hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors" title="Supprimer cet utilisateur">
<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" />
</svg>
</button>
</form>
{% endif %}
</div>
{% if u.id == current_user.id %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Protection: modification/suppression impossible</p>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-12 text-center">
<svg class="w-16 h-16 mx-auto text-gray-400 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<p class="text-gray-500 dark:text-gray-400 text-lg">Aucun utilisateur enregistré</p>
</div>
{% endif %}
</div>
<!-- Légende des rôles -->
{% if users %}
<div class="mt-6 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800 p-6">
<h3 class="text-lg font-semibold text-blue-900 dark:text-blue-200 mb-4 flex items-center gap-2">
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Hiérarchie des rôles</span>
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{% for role_name in roles %}
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 border border-blue-100 dark:border-blue-900">
<div class="flex items-center gap-2 mb-1">
<span class="w-3 h-3 rounded-full" style="background-color: #6B7280;"></span>
<span class="font-medium text-gray-900 dark:text-white text-sm">{{ role_labels.get(role_name, role_name) }}</span>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400 ml-5">
{% if role_name == 'viewer_twitch' %}
Accès minimal, consultation uniquement
{% elif role_name == 'utilisateur_discord' %}
Modification de contenu basique
{% elif role_name == 'moderateur_discord' %}
Outils de modération Discord
{% elif role_name == 'expert_discord' %}
Gestion avancée du contenu
{% elif role_name == 'moderateur_twitch' %}
Outils de modération Twitch
{% elif role_name == 'super_administrateur' %}
Accès complet au système
{% else %}
Rôle personnalisé
{% endif %}
</p>
</div>
{% endfor %}
</div>
<div class="mt-4 p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<strong>💡 Conseil :</strong> Vous pouvez personnaliser les rôles et leurs permissions dans la page
<a href="{{ url_for('settings') }}" class="underline hover:text-blue-900 dark:hover:text-blue-100">Paramètres</a>.
</p>
</div>
</div>
{% endif %}
<!-- Modal de création d'utilisateur -->
<div id="create-user-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" onclick="document.getElementById('create-user-modal').classList.add('hidden')"></div>
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<form action="{{ url_for('users_create') }}" method="post">
<div class="bg-white dark:bg-gray-800 px-6 pt-6 pb-4">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
<svg class="w-6 h-6 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Créer un utilisateur</h3>
</div>
<button type="button" onclick="document.getElementById('create-user-modal').classList.add('hidden')"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Nom d'utilisateur <span class="text-red-500">*</span>
</label>
<input type="text" id="username" name="username" required minlength="3"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
placeholder="ex: john_doe">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Adresse e-mail <span class="text-red-500">*</span>
</label>
<input type="email" id="email" name="email" required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
placeholder="ex: john@example.com">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Mot de passe <span class="text-red-500">*</span>
</label>
<input type="password" id="password" name="password" required minlength="8"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
placeholder="Au moins 8 caractères">
</div>
<div>
<label for="password_confirm" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirmer le mot de passe <span class="text-red-500">*</span>
</label>
<input type="password" id="password_confirm" name="password_confirm" required minlength="8"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
placeholder="Retapez le mot de passe">
</div>
<div>
<label for="role" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Rôle <span class="text-red-500">*</span>
</label>
<select id="role" name="role" required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
{% for r in roles %}
<option value="{{ r }}">{{ role_labels.get(r, r) }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 flex gap-3 justify-end">
<button type="button" onclick="document.getElementById('create-user-modal').classList.add('hidden')"
class="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
Annuler
</button>
<button type="submit"
class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
Créer l'utilisateur
</button>
</div>
</form>
</div>
</div>
</div>
<style>
/* Animation au survol des lignes */
tbody tr {
transition: all 0.2s ease;
}
</style>
{% endblock %}
-106
View File
@@ -1,106 +0,0 @@
{% extends "template.html" %}
{% block content %}
<h1>Alertes YouTube</h1>
<p>
Liste des chaînes YouTube surveillées pour les alertes de nouvelles vidéos.
Le bot vérifie régulièrement les nouvelles vidéos publiées sur les chaînes configurées.
Lorsqu'une nouvelle vidéo est détectée, le bot envoie une notification sur le canal Discord configuré.
</p>
{% if not alert %}
<h2>Chaînes surveillées</h2>
<table class="youtube-alert">
<thead>
<tr>
<th>Chaîne</th>
<th>Canal Discord</th>
<th>Message</th>
<th>Dernière vidéo</th>
<th>#</th>
</tr>
</thead>
<tbody>
{% for alert in alerts %}
<tr>
<td>
<a href="https://www.youtube.com/channel/{{alert.channel_id}}" target="_blank">
{{alert.channel_name or alert.channel_id}}
</a>
</td>
<td>{{alert.notify_channel_name}}</td>
<td>{{alert.message[:50]}}{% if alert.message|length > 50 %}...{% endif %}</td>
<td class="last-video">
{% if alert.last_video_title %}
<a href="https://www.youtube.com/watch?v={{alert.last_video_id}}" target="_blank" title="{{alert.last_video_title}}">
{{alert.last_video_title[:40]}}{% if alert.last_video_title|length > 40 %}...{% endif %}
</a>
<br>
<small>📅 {{alert.last_video_date.strftime('%d/%m/%Y à %H:%M') if alert.last_video_date else '-'}}</small>
{% else %}
<span class="no-video">Aucune vidéo détectée</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('toggleYoutubeAlert', id = alert.id) }}" class="icon">{{ '✅' if alert.enable else '❌' }}</a>
<a href="{{ url_for('openEditYoutubeAlert', id = alert.id) }}" class="icon"></a>
<a href="{{ url_for('delYoutubeAlert', id = alert.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette alerte ?')" class="icon">🗑</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h2>{{ 'Editer une alerte' if alert else 'Ajouter une alerte YouTube' }}</h2>
<form action="{{ url_for('submitEditYoutubeAlert', id = alert.id) if alert else url_for('addYoutubeAlert') }}" method="POST">
<label for="channel_id">ID de la chaîne YouTube</label>
<input name="channel_id" type="text" maxlength="64" required="required" value="{{alert.channel_id if alert}}" placeholder="UCxxxxxxxxxxxxxxxxxxxxxxxx"/>
<label for="notify_channel">Canal de Notification Discord</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 else '🎬 **Nouvelle vidéo !**\n\n**{0.author}** vient de publier : **{0.title}**\n\n👉 {0.link}'}}</textarea>
<input type="Submit" value="{{ 'Modifier' if alert else 'Ajouter' }}">
<h3>Comment trouver l'ID de chaîne YouTube ?</h3>
<p>
L'ID de chaîne YouTube commence par <code>UC</code> et contient 24 caractères.
</p>
<ol>
<li>Allez sur la page de la chaîne YouTube</li>
<li>Cliquez droit sur la page et choisissez "Afficher le code source"</li>
<li>Recherchez <code>channelId</code> ou <code>externalId</code></li>
<li>Copiez l'ID qui ressemble à : <code>UCxxxxxxxxxxxxxxxxxxxxxxxx</code></li>
</ol>
<p>
Vous pouvez aussi utiliser des outils en ligne comme <a href="https://commentpicker.com/youtube-channel-id.php" target="_blank">Comment Picker</a> pour trouver l'ID.
</p>
<h3>Variables disponibles pour le message</h3>
<p>
Pour le message vous avez accès à ces variables :
</p>
<ul>
<li><code>{0.title}</code> : Titre de la vidéo</li>
<li><code>{0.author}</code> : Nom de la chaîne</li>
<li><code>{0.link}</code> : Lien vers la vidéo</li>
<li><code>{0.video_id}</code> : ID de la vidéo</li>
<li><code>{0.thumbnail}</code> : URL de la miniature</li>
</ul>
<p>
Le message est au format <a href="https://commonmark.org/" target="_blank">common-mark</a> dans la limite de ce que supporte Discord.
</p>
</form>
{% endblock %}
+342
View File
@@ -0,0 +1,342 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Notifications YouTube</h1>
{% if msg %}
<div id="alert-msg" class="mb-4 p-4 rounded-lg {{ 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300' if msg_type == 'error' else 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300' }}">
{{ msg }}
</div>
<script>
setTimeout(function() {
var el = document.getElementById('alert-msg');
if (el) el.style.display = 'none';
}, 5000);
</script>
{% endif %}
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
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>
</div>
</div>
{% if not notification %}
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Notifications configurées</h2>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Chaîne YouTube</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Canal Discord</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Type</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for notification in notifications %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<code class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-red-600 dark:text-red-400 font-mono text-sm">{{ notification.channel_id }}</code>
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-700 dark:text-gray-300">{{ notification.notify_channel_name }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-medium rounded-full
{% if notification.video_type == 'all' %}bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300
{% elif notification.video_type == 'video' %}bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300
{% else %}bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300{% endif %}">
{% if notification.video_type == 'all' %}Toutes
{% elif notification.video_type == 'video' %}Vidéos
{% else %}Shorts{% endif %}
</span>
</td>
<td class="px-6 py-4 text-gray-600 dark:text-gray-400 max-w-xs truncate">{{ notification.message }}</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
<a href="{{ url_for('toggleYouTube', id = notification.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
title="{{ 'Désactiver' if notification.enable else 'Activer' }}">
{{ '✅' if notification.enable else '❌' }}
</a>
<a href="{{ url_for('openEditYouTube', id = notification.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors text-blue-600 dark:text-blue-400"
title="Modifier">
<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 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
</a>
<a href="{{ url_for('delYouTube', id = notification.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette notification ?')"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400"
title="Supprimer">
<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="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>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
Aucune notification configurée. Ajoutez-en une ci-dessous.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
{{ 'Modifier la notification' if notification else 'Ajouter une notification YouTube' }}
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<form id="youtube-form" action="{{ url_for('submitEditYouTube', id = notification.id) if notification else url_for('addYouTube') }}" method="POST" class="space-y-6">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Configuration de base</h3>
<div>
<label for="channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Lien ou ID de la chaîne YouTube</label>
<input name="channel_id" id="channel_id" type="text" maxlength="256" required
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
placeholder="https://www.youtube.com/@513v3 ou UC..."
value="{{notification.channel_id if notification}}"/>
</div>
<div>
<label for="notify_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de notification Discord</label>
<select name="notify_channel" id="notify_channel"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all">
{% for channel in channels %}
<option value="{{channel.id}}" {% if notification and notification.notify_channel == channel.id %}selected{% endif %}>{{channel.name}}</option>
{% endfor %}
</select>
</div>
<div>
<label for="video_type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Type de vidéo à notifier</label>
<select name="video_type" id="video_type"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all">
<option value="all" {% if notification and notification.video_type == 'all' %}selected{% endif %}>Toutes (vidéos + shorts)</option>
<option value="video" {% if notification and notification.video_type == 'video' %}selected{% endif %}>Vidéos uniquement</option>
<option value="short" {% if notification and notification.video_type == 'short' %}selected{% endif %}>Shorts uniquement</option>
</select>
</div>
<div>
<label for="message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message (optionnel)</label>
<textarea name="message" id="message" rows="2"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all resize-y"
placeholder="Message envoyé avant l'embed">{{notification.message if notification}}</textarea>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Personnalisation de l'embed Discord</h3>
<div>
<label for="embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre de l'embed</label>
<input name="embed_title" id="embed_title" type="text" maxlength="256"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
placeholder="{video_title}"
value="{{notification.embed_title if notification}}"/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Variables: {video_title}, {channel_name}, {video_url}, {video_id}</p>
</div>
<div>
<label for="embed_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description de l'embed</label>
<textarea name="embed_description" id="embed_description" rows="2"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all resize-y"
placeholder="Description optionnelle">{{notification.embed_description if notification}}</textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="embed_color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Couleur</label>
<div class="flex items-center gap-2">
<input name="embed_color" id="embed_color" type="color"
class="w-12 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
value="#{{notification.embed_color if notification else 'FF0000'}}"/>
<input type="text" id="embed_color_text" maxlength="6"
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
value="{{notification.embed_color if notification else 'FF0000'}}" placeholder="FF0000"/>
</div>
</div>
<div>
<label for="embed_author_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom de l'auteur</label>
<input name="embed_author_name" id="embed_author_name" type="text" maxlength="256"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
placeholder="{channel_name}"
value="{{notification.embed_author_name if notification}}"/>
</div>
</div>
<div>
<label for="embed_author_icon" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icône de l'auteur (URL)</label>
<input name="embed_author_icon" id="embed_author_icon" type="text" maxlength="512"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
placeholder="https://www.youtube.com/img/desktop/yt_1200.png"
value="{{notification.embed_author_icon if notification}}"/>
</div>
<div>
<label for="embed_footer" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Pied de page</label>
<input name="embed_footer" id="embed_footer" type="text" maxlength="2048"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
placeholder="Texte optionnel en bas"
value="{{notification.embed_footer if notification}}"/>
</div>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="embed_thumbnail" id="embed_thumbnail"
{% if not notification or notification.embed_thumbnail %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-red-600 focus:ring-red-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Miniature</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="embed_image" id="embed_image"
{% if not notification or notification.embed_image %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-red-600 focus:ring-red-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Image principale</span>
</label>
</div>
</div>
<div class="flex items-center gap-4">
<button type="submit"
class="px-6 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
{{ 'Enregistrer' if notification else 'Ajouter la notification' }}
</button>
{% if notification %}
<a href="{{ url_for('openYouTube') }}"
class="px-6 py-2.5 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 font-medium rounded-lg transition-colors">
Annuler
</a>
{% endif %}
</div>
</form>
</div>
<div>
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-4">Prévisualisation de l'embed Discord</h3>
<div id="embed-preview" class="bg-[#2f3136] rounded p-4 font-sans text-[#dcddde] max-w-xl border-l-4" style="border-left-color: #FF0000;">
<div id="embed-author" class="flex items-center mb-2 text-sm">
<img id="embed-author-icon" src="https://www.youtube.com/img/desktop/yt_1200.png" class="w-5 h-5 rounded-full mr-2" onerror="this.style.display='none'"/>
<span id="embed-author-name" class="font-semibold">Nom de la chaîne</span>
</div>
<a id="embed-title" href="#" class="text-[#00aff4] no-underline text-base font-semibold block mb-2">Titre de la vidéo</a>
<div id="embed-description" class="text-sm leading-relaxed mb-2 text-[#dcddde]"></div>
<div id="embed-thumbnail-container" class="my-2">
<img id="embed-thumbnail" src="" class="max-w-[80px] max-h-[80px] rounded float-right ml-4 hidden"/>
</div>
<div id="embed-image-container" class="mt-4">
<img id="embed-image" src="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" class="max-w-full rounded hidden"/>
</div>
<div id="embed-footer" class="mt-2 text-xs text-[#72767d]"></div>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Cette prévisualisation est approximative.</p>
<div class="mt-6 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 class="font-medium text-gray-800 dark:text-gray-200 mb-2">Variables disponibles</h4>
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{channel_name}</code> — Nom de la chaîne</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{video_title}</code> — Titre de la vidéo</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{video_url}</code> — Lien vers la vidéo</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{video_id}</code> — ID de la vidéo</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{thumbnail}</code> — URL de la miniature</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{published_at}</code> — Date de publication</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{is_short}</code> — True si c'est un short</li>
</ul>
</div>
</div>
</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>
{% endblock %}
+38 -17
View File
@@ -1,50 +1,71 @@
import asyncio
import logging
from flask import render_template, request, redirect, url_for
from flask import render_template, request, redirect, url_for, flash
from twitchAPI.twitch import Twitch
from twitchAPI.type import TwitchAPIException
from twitchAPI.type import TwitchAPIException
from twitchAPI.oauth import UserAuthenticator
from database import db
from database.helpers import ConfigurationHelper
from twitchbot import USER_SCOPE
from webapp import webapp
from webapp.auth import require_page
auth: UserAuthenticator
@webapp.route("/configurations/twitch/help")
def twitchConfigurationHelp():
return render_template("twitch-aide.html", token_redirect_url = _buildUrl())
@webapp.route("/configurations/twitch/request-token")
async def twitchRequestToken():
@webapp.route("/configurations/twitch/help")
@require_page("configurations")
def twitchConfigurationHelp():
return render_template("twitch-aide.html", token_redirect_url=_buildUrl())
@webapp.route("/configurations/twitch/request-token")
@require_page("configurations")
def twitchRequestToken():
global auth
helper = ConfigurationHelper()
twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
twitch = Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
auth = UserAuthenticator(twitch, USER_SCOPE, url=_buildUrl())
return redirect(auth.return_auth_url())
@webapp.route("/configurations/twitch/receive-token")
async def twitchReceiveToken():
@webapp.route("/configurations/twitch/receive-token")
def twitchReceiveToken():
global auth
state = request.args.get('state')
code = request.args.get('code')
if state != auth.state :
logging('bad returned state')
logging.info("Callback Twitch reçu - state: %s, code: %s", state, code is not None)
if not hasattr(auth, 'state') or auth is None:
logging.error('Objet auth non initialisé - veuillez réessayer')
return redirect(url_for('openConfigurations'))
if code == None :
logging('no returned state')
if state != auth.state:
logging.error('State invalide - attendu: %s, reçu: %s', auth.state, state)
return redirect(url_for('openConfigurations'))
if code is None:
logging.error('Pas de code retourné par Twitch')
return redirect(url_for('openConfigurations'))
try:
token, refresh = await auth.authenticate(user_token=code)
token, refresh = asyncio.run(auth.authenticate(user_token=code))
logging.info('Tokens Twitch obtenus avec succès')
helper = ConfigurationHelper()
helper.createOrUpdate('twitch_access_token', token)
helper.createOrUpdate('twitch_refresh_token', refresh)
db.session.commit()
logging.info('Tokens Twitch sauvegardés en base de données')
flash('Token Twitch enregistré. Redémarrez l\'application pour que le bot utilise le nouveau token.', 'success')
except TwitchAPIException as e:
logging(e)
logging.error('Erreur API Twitch: %s', e)
flash(f'Erreur API Twitch : {e}', 'error')
except Exception as e:
logging.error('Erreur inattendue: %s', e)
flash(f'Erreur inattendue : {e}', 'error')
return redirect(url_for('openConfigurations'))
# hack pas fou mais on estime qu'on sera toujours en ssl en connecté
+81
View File
@@ -0,0 +1,81 @@
# Notifications d'événements Twitch : sub, follow, raid, clip (chat + Discord)
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import TwitchEventNotification
from discordbot import bot
EVENT_LABELS = {
"sub": "Abonnement (sub)",
"follow": "Nouveau follow",
"raid": "Raid reçu",
"clip": "Nouveau clip",
}
@webapp.route("/twitch-events")
@require_page("twitch_events")
def open_twitch_events():
configs = TwitchEventNotification.query.order_by(TwitchEventNotification.event_type).all()
# S'assurer qu'il existe une config par type
existing = {c.event_type for c in configs}
for ev in ("sub", "follow", "raid", "clip"):
if ev not in existing:
cfg = TwitchEventNotification(
event_type=ev,
message_twitch="Merci {user} !" if ev != "raid" else "Bienvenue aux viewers de {from_broadcaster_name} !",
)
db.session.add(cfg)
configs.append(cfg)
db.session.commit()
channels = bot.getAllTextChannel()
# Nom du canal Discord pour l'affichage
for c in configs:
if c.discord_channel_id:
c.discord_channel_name = next((ch.name for ch in channels if ch.id == c.discord_channel_id), None)
else:
c.discord_channel_name = None
return render_template("twitch-events.html", configs=configs, channels=channels, labels=EVENT_LABELS)
@webapp.route("/twitch-events/save", methods=["POST"])
@require_page("twitch_events")
def save_twitch_events():
if not can_write_page("twitch_events"):
return render_template("403.html"), 403
for ev in ("sub", "follow", "raid", "clip"):
cfg = TwitchEventNotification.query.filter_by(event_type=ev).first()
if not cfg:
cfg = TwitchEventNotification(event_type=ev)
db.session.add(cfg)
prefix = f"ev_{ev}_"
cfg.enable = request.form.get(prefix + "enable") == "1"
cfg.notify_twitch_chat = request.form.get(prefix + "notify_twitch_chat") == "1"
cfg.notify_discord = request.form.get(prefix + "notify_discord") == "1"
ch_id = request.form.get(prefix + "discord_channel_id")
cfg.discord_channel_id = int(ch_id) if ch_id and ch_id.isdigit() else None
cfg.message_twitch = (request.form.get(prefix + "message_twitch") or "").strip()[:500]
cfg.message_discord = (request.form.get(prefix + "message_discord") or "").strip()[:2000] or None
embed_color = (request.form.get(prefix + "embed_color") or "9146FF").strip().lstrip("#")[:6]
cfg.embed_color = embed_color if len(embed_color) == 6 else "9146FF"
cfg.embed_title = (request.form.get(prefix + "embed_title") or "").strip()[:256] or None
cfg.embed_description = (request.form.get(prefix + "embed_description") or "").strip()[:2000] or None
cfg.embed_thumbnail = request.form.get(prefix + "embed_thumbnail") == "1"
db.session.commit()
return redirect(url_for("open_twitch_events"))
@webapp.route("/twitch-events/toggle/<event_type>")
@require_page("twitch_events")
def toggle_twitch_event(event_type):
if not can_write_page("twitch_events"):
return render_template("403.html"), 403
if event_type not in ("sub", "follow", "raid", "clip"):
return redirect(url_for("open_twitch_events"))
cfg = TwitchEventNotification.query.filter_by(event_type=event_type).first()
if cfg:
cfg.enable = not cfg.enable
db.session.commit()
return redirect(url_for("open_twitch_events"))
+403
View File
@@ -0,0 +1,403 @@
from flask import render_template, request, redirect, url_for, jsonify
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import Commande, TwitchModerationLog, TwitchLinkFilter, TwitchBannedWord
from database.helpers import ConfigurationHelper
from datetime import datetime, timedelta
import asyncio
MODERATION_COMMANDS = [
{
"commands": ["!kick", "!to", "!timeout", "!tm"],
"usage": "!timeout <viewer> [minutes] [raison]",
"description": "Ejection temporaire d'un viewer (3 minutes par defaut) avec raison optionnelle",
"permission": "Moderateur"
},
{
"commands": ["!ban"],
"usage": "!ban <viewer1> [viewer2] ...",
"description": "Bannissement d'un ou plusieurs viewers (max 5)",
"permission": "Moderateur"
},
{
"commands": ["!unban"],
"usage": "!unban <viewer1> [viewer2] ...",
"description": "Debannissement d'un ou plusieurs viewers (max 5)",
"permission": "Moderateur"
},
{
"commands": ["!clean"],
"usage": "!clean [viewer]",
"description": "Nettoyage du chat ou des messages d'un viewer",
"permission": "Moderateur"
},
{
"commands": ["!shieldmode"],
"usage": "!shieldmode <on/off>",
"description": "Active/desactive le mode Shield de Twitch",
"permission": "Moderateur"
},
{
"commands": ["!settitle"],
"usage": "!settitle <titre>",
"description": "Changement du titre du live",
"permission": "Moderateur"
},
{
"commands": ["!setgame", "!setcateg"],
"usage": "!setgame <jeu>",
"description": "Changement du jeu/categorie du live",
"permission": "Moderateur"
},
{
"commands": ["!subon"],
"usage": "!subon",
"description": "Activation du mode abonnes uniquement",
"permission": "Moderateur"
},
{
"commands": ["!suboff"],
"usage": "!suboff",
"description": "Desactivation du mode abonnes uniquement",
"permission": "Moderateur"
},
{
"commands": ["!follon"],
"usage": "!follon [minutes]",
"description": "Activation du mode followers-only",
"permission": "Moderateur"
},
{
"commands": ["!folloff"],
"usage": "!folloff",
"description": "Desactivation du mode followers-only",
"permission": "Moderateur"
},
{
"commands": ["!emoteon"],
"usage": "!emoteon",
"description": "Activation du mode emote-only",
"permission": "Moderateur"
},
{
"commands": ["!emoteoff"],
"usage": "!emoteoff",
"description": "Desactivation du mode emote-only",
"permission": "Moderateur"
},
{
"commands": ["!ann"],
"usage": "!ann <alias> <on/off/toggle>",
"description": "Activer/desactiver/inverser une liste d'annonce par alias",
"permission": "Moderateur"
},
{
"commands": ["!no_game"],
"usage": "!no_game <on/off>",
"description": "Desactiver/activer tous les jeux de la chaine",
"permission": "Moderateur"
},
{
"commands": ["!multitwitch"],
"usage": "!multitwitch [live1] [live2] ... | auto | reset",
"description": "Creation d'un lien MultiTwitch. '@' = chaine actuelle, 'auto' = depuis le titre, 'reset' = reinitialiser",
"permission": "Moderateur (creation) / Tous (affichage)"
},
{
"commands": ["!permit"],
"usage": "!permit <viewer> [minutes]",
"description": "Autorise temporairement un viewer a poster un lien (1 minute par defaut)",
"permission": "Moderateur"
},
]
TWITCH_PERMISSIONS = {'viewer': 'Tous (viewers)', 'sub': 'Abonnés', 'vip': 'VIP', 'moderator': 'Modérateur'}
@webapp.route("/twitch-moderation")
@require_page("twitch_moderation")
def twitch_moderation():
custom_commands = Commande.query.filter_by(twitch_enable=True).all()
logs = TwitchModerationLog.query.order_by(TwitchModerationLog.created_at.desc()).limit(50).all()
raw_channel = ConfigurationHelper().getValue("twitch_channel") or webapp.config["BOT_STATUS"].get("twitch_channel_name") or "chainesteve"
twitch_channel = (raw_channel or "").strip().lower() or "chainesteve"
embed_parent = request.host or "localhost"
# Link filter status
link_filter_config = TwitchLinkFilter.query.first()
link_filter_enabled = link_filter_config.enabled if link_filter_config else False
# Banned words
banned_words = TwitchBannedWord.query.filter_by(enabled=True).all()
# Live status (from BOT_STATUS)
bot_status = webapp.config.get("BOT_STATUS", {})
is_live = bot_status.get("twitch_is_live", False)
viewer_count = bot_status.get("twitch_viewer_count", 0)
return render_template(
"twitch-moderation.html",
commands=MODERATION_COMMANDS,
custom_commands=custom_commands,
logs=logs,
twitch_permissions=TWITCH_PERMISSIONS,
twitch_channel=twitch_channel,
embed_parent=embed_parent,
link_filter_enabled=link_filter_enabled,
banned_words=banned_words,
is_live=is_live,
viewer_count=viewer_count,
)
@webapp.route("/twitch-moderation/logs/clear")
@require_page("twitch_moderation")
def clear_twitch_logs():
if not can_write_page("twitch_moderation"):
return render_template("403.html"), 403
TwitchModerationLog.query.delete()
db.session.commit()
return redirect(url_for('twitch_moderation'))
@webapp.route("/twitch-moderation/add", methods=['POST'])
@require_page("twitch_moderation")
def add_twitch_commande():
if not can_write_page("twitch_moderation"):
return render_template("403.html"), 403
trigger = request.form.get('trigger')
response = request.form.get('response')
twitch_permission = request.form.get('twitch_permission') or 'viewer'
if twitch_permission not in TWITCH_PERMISSIONS:
twitch_permission = 'viewer'
if trigger and response:
if not trigger.startswith('!'):
trigger = '!' + trigger
existing = Commande.query.filter_by(trigger=trigger).first()
if not existing:
commande = Commande(trigger=trigger, response=response, discord_enable=False, twitch_enable=True, twitch_permission=twitch_permission)
db.session.add(commande)
db.session.commit()
return redirect(url_for('twitch_moderation'))
@webapp.route("/twitch-moderation/banned-word/add", methods=['POST'])
@require_page("twitch_moderation")
def add_banned_word():
if not can_write_page("twitch_moderation"):
return render_template("403.html"), 403
word = request.form.get('word', '').strip().lower()
timeout_duration = int(request.form.get('timeout_duration', 60))
if word:
existing = TwitchBannedWord.query.filter_by(word=word).first()
if not existing:
banned_word = TwitchBannedWord(word=word, enabled=True, timeout_duration=timeout_duration)
db.session.add(banned_word)
db.session.commit()
return redirect(url_for('twitch_moderation'))
@webapp.route("/twitch-moderation/banned-word/delete/<int:word_id>")
@require_page("twitch_moderation")
def delete_banned_word(word_id):
if not can_write_page("twitch_moderation"):
return render_template("403.html"), 403
banned_word = TwitchBannedWord.query.get_or_404(word_id)
db.session.delete(banned_word)
db.session.commit()
return redirect(url_for('twitch_moderation'))
@webapp.route("/twitch-moderation/send-message", methods=['POST'])
@require_page("twitch_moderation")
def send_twitch_message():
if not can_write_page("twitch_moderation"):
return jsonify({"success": False, "error": "Permission refusée"}), 403
data = request.get_json()
message = data.get('message', '').strip()
if not message:
return jsonify({"success": False, "error": "Message vide"}), 400
# Vérifier que le bot Twitch est connecté
from twitchbot import twitchBot
if not hasattr(twitchBot, 'chat') or not twitchBot.chat:
return jsonify({"success": False, "error": "Bot Twitch non connecté"}), 503
# Récupérer le nom du channel
channel = ConfigurationHelper().getValue('twitch_channel')
if not channel:
return jsonify({"success": False, "error": "Channel Twitch non configuré"}), 400
# Envoyer le message de manière asynchrone
try:
async def send_msg():
try:
await twitchBot.chat.send_message(channel, message)
return True
except Exception as e:
return str(e)
# Exécuter la coroutine de manière synchrone
loop = asyncio.new_event_loop()
result = loop.run_until_complete(send_msg())
loop.close()
if result is True:
return jsonify({"success": True})
else:
return jsonify({"success": False, "error": f"Erreur: {result}"}), 500
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@webapp.route("/twitch-moderation/messages")
@require_page("twitch_moderation")
def get_twitch_messages():
"""Retourne les derniers messages du chat Twitch"""
messages = webapp.config["BOT_STATUS"].get("twitch_chat_messages", [])
return jsonify({"messages": messages})
@webapp.route("/twitch-moderation/execute-action", methods=['POST'])
@require_page("twitch_moderation")
def execute_moderation_action():
"""Exécute une action de modération directement"""
if not can_write_page("twitch_moderation"):
return jsonify({"success": False, "error": "Permission refusée"}), 403
data = request.get_json()
action = data.get('action', '').strip()
params = data.get('params', {})
if not action:
return jsonify({"success": False, "error": "Action non spécifiée"}), 400
# Vérifier que le bot Twitch est connecté
from twitchbot import twitchBot
if not hasattr(twitchBot, 'chat') or not twitchBot.chat or not hasattr(twitchBot, 'twitch'):
return jsonify({"success": False, "error": "Bot Twitch non connecté"}), 503
# Récupérer le nom du channel
channel = ConfigurationHelper().getValue('twitch_channel')
if not channel:
return jsonify({"success": False, "error": "Channel Twitch non configuré"}), 400
# Créer un objet ChatMessage simulé pour les commandes qui en ont besoin
from twitchAPI.chat import ChatMessage
from types import SimpleNamespace
# Exécuter l'action de manière asynchrone
try:
async def execute_action():
try:
if action == 'timeout':
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
username = params.get('username', '').strip().lstrip('@')
duration = int(params.get('duration', 600)) # en secondes
reason = params.get('reason', 'Timeout')
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
moderator_id = await _get_moderator_id(twitchBot.twitch)
user_id = await _get_user_id(twitchBot.twitch, username)
if user_id:
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason, duration=duration)
_log_action("timeout", "WebApp", username, f"{duration}s - {reason}")
return {"success": True, "message": f"Timeout de {username} pour {duration}s"}
return {"success": False, "error": f"Utilisateur {username} introuvable"}
elif action == 'ban':
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
username = params.get('username', '').strip().lstrip('@')
reason = params.get('reason', 'Ban')
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
moderator_id = await _get_moderator_id(twitchBot.twitch)
user_id = await _get_user_id(twitchBot.twitch, username)
if user_id:
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason)
_log_action("ban", "WebApp", username, reason)
return {"success": True, "message": f"Ban de {username}"}
return {"success": False, "error": f"Utilisateur {username} introuvable"}
elif action == 'clean':
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
username = params.get('username', '').strip().lstrip('@')
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
moderator_id = await _get_moderator_id(twitchBot.twitch)
if username:
user_id = await _get_user_id(twitchBot.twitch, username)
if user_id:
await twitchBot.twitch.delete_chat_messages(broadcaster_id, moderator_id, user_id=user_id)
_log_action("clean", "WebApp", username)
return {"success": True, "message": f"Messages de {username} supprimés"}
return {"success": False, "error": f"Utilisateur {username} introuvable"}
else:
await twitchBot.twitch.delete_chat_messages(broadcaster_id, moderator_id)
_log_action("clean", "WebApp", None, "Chat complet")
return {"success": True, "message": "Chat nettoyé"}
elif action == 'permit':
from database.models import TwitchPermit
username = params.get('username', '').strip().lstrip('@').lower()
duration = int(params.get('duration', 60)) # en secondes
expires_at = datetime.now() + timedelta(seconds=duration)
with webapp.app_context():
existing = TwitchPermit.query.filter_by(username=username).first()
if existing:
existing.expires_at = expires_at
else:
permit = TwitchPermit(username=username, expires_at=expires_at)
db.session.add(permit)
db.session.commit()
return {"success": True, "message": f"Permit accordé à {username} pour {duration//60}min"}
elif action in ['subon', 'suboff', 'emoteon', 'emoteoff']:
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _log_action
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
moderator_id = await _get_moderator_id(twitchBot.twitch)
if action == 'subon':
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=True)
_log_action("subon", "WebApp")
return {"success": True, "message": "Mode abonnés activé"}
elif action == 'suboff':
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=False)
_log_action("suboff", "WebApp")
return {"success": True, "message": "Mode abonnés désactivé"}
elif action == 'emoteon':
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=True)
_log_action("emoteon", "WebApp")
return {"success": True, "message": "Mode emote activé"}
elif action == 'emoteoff':
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=False)
_log_action("emoteoff", "WebApp")
return {"success": True, "message": "Mode emote désactivé"}
return {"success": False, "error": f"Action '{action}' non reconnue"}
except Exception as e:
import logging
logging.error(f"Erreur lors de l'exécution de l'action {action}: {e}")
return {"success": False, "error": str(e)}
# Exécuter la coroutine de manière synchrone
loop = asyncio.new_event_loop()
result = loop.run_until_complete(execute_action())
loop.close()
return jsonify(result)
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
+119
View File
@@ -0,0 +1,119 @@
# Gestion des utilisateurs webapp (réservé super administrateur).
from flask import render_template, request, redirect, url_for, flash
from werkzeug.security import generate_password_hash
from webapp import webapp
from webapp.auth import require_page
from database import db
from database.models import WebappUser, WebappRole
ROLE_LABELS = {
"viewer_twitch": "Viewer Twitch",
"utilisateur_discord": "Utilisateur Discord",
"moderateur_discord": "Modérateur Discord",
"expert_discord": "Expert Discord",
"moderateur_twitch": "Modérateur Twitch",
"super_administrateur": "Super administrateur",
}
def _role_labels():
roles = WebappRole.query.order_by(WebappRole.level).all()
return {r.name: r.name.replace("_", " ").title() for r in roles}
@webapp.route("/users")
@require_page("users")
def users_list():
users = WebappUser.query.order_by(WebappUser.created_at.desc()).all()
roles = WebappRole.query.order_by(WebappRole.level).all()
labels = dict(ROLE_LABELS)
labels.update(_role_labels())
return render_template(
"users.html",
users=users,
roles=[r.name for r in roles],
role_labels=labels,
)
@webapp.route("/users/role/<int:user_id>", methods=["POST"])
@require_page("users")
def users_set_role(user_id):
user = WebappUser.query.get_or_404(user_id)
new_role = request.form.get("role")
existing = WebappRole.query.filter_by(name=new_role).first()
if new_role and existing:
user.role = new_role
db.session.commit()
return redirect(url_for("users_list"))
@webapp.route("/users/create", methods=["POST"])
@require_page("users")
def users_create():
"""Création d'un utilisateur par un administrateur."""
username = (request.form.get("username") or "").strip()
email = (request.form.get("email") or "").strip().lower()
password = request.form.get("password") or ""
password_confirm = request.form.get("password_confirm") or ""
role = request.form.get("role") or "viewer_twitch"
errors = []
# Validations
if len(username) < 3:
errors.append("Le nom d'utilisateur doit faire au moins 3 caractères.")
if len(email) < 5 or "@" not in email:
errors.append("Adresse e-mail invalide.")
if len(password) < 8:
errors.append("Le mot de passe doit faire au moins 8 caractères.")
if password != password_confirm:
errors.append("Les mots de passe ne correspondent pas.")
if WebappUser.query.filter_by(username=username).first():
errors.append("Ce nom d'utilisateur est déjà pris.")
if WebappUser.query.filter_by(email=email).first():
errors.append("Cette adresse e-mail est déjà utilisée.")
# Vérifier que le rôle existe
if not WebappRole.query.filter_by(name=role).first():
errors.append("Rôle invalide.")
if errors:
for msg in errors:
flash(msg, "error")
return redirect(url_for("users_list"))
# Créer l'utilisateur
user = WebappUser(
username=username,
email=email,
password_hash=generate_password_hash(password, method="scrypt"),
role=role,
)
db.session.add(user)
db.session.commit()
flash(f"Utilisateur « {username} » créé avec succès.", "success")
return redirect(url_for("users_list"))
@webapp.route("/users/delete/<int:user_id>", methods=["POST"])
@require_page("users")
def users_delete(user_id):
"""Suppression d'un utilisateur (sauf soi-même)."""
from flask_login import current_user
user = WebappUser.query.get_or_404(user_id)
# Protection : impossible de se supprimer soi-même
if user.id == current_user.id:
flash("Vous ne pouvez pas supprimer votre propre compte.", "error")
return redirect(url_for("users_list"))
username = user.username
db.session.delete(user)
db.session.commit()
flash(f"Utilisateur « {username} » supprimé.", "success")
return redirect(url_for("users_list"))
+195
View File
@@ -0,0 +1,195 @@
import re
import requests
from urllib.parse import urlencode
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
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")
@require_page("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'])
@require_page("youtube")
def addYouTube():
if not can_write_page("youtube"):
return render_template("403.html"), 403
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>")
@require_page("youtube")
def toggleYouTube(id):
if not can_write_page("youtube"):
return render_template("403.html"), 403
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>")
@require_page("youtube")
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'])
@require_page("youtube")
def submitEditYouTube(id):
if not can_write_page("youtube"):
return render_template("403.html"), 403
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>")
@require_page("youtube")
def delYouTube(id):
if not can_write_page("youtube"):
return render_template("403.html"), 403
notification = YouTubeNotification.query.get_or_404(id)
db.session.delete(notification)
db.session.commit()
return redirect(url_for("openYouTube"))
-88
View File
@@ -1,88 +0,0 @@
from flask import render_template, request, redirect, url_for, flash
from webapp import webapp
from database import db
from database.models import YoutubeAlert
from discordbot import bot
from youtubebot.youtube_alert import _get_channel_name
@webapp.route("/youtube-alert")
def openYoutubeAlert():
alerts: list[YoutubeAlert] = YoutubeAlert.query.all()
channels = bot.getAllTextChannel()
for alert in alerts:
for channel in channels:
if alert.notify_channel == channel.id:
alert.notify_channel_name = channel.name
return render_template("youtube-alert.html", alerts=alerts, channels=channels)
@webapp.route("/youtube-alert/add", methods=['POST'])
def addYoutubeAlert():
channel_id = request.form.get('channel_id').strip()
existing = YoutubeAlert.query.filter_by(channel_id=channel_id).first()
if existing:
flash("Cette chaîne YouTube est déjà surveillée.", "error")
return redirect(url_for("openYoutubeAlert"))
channel_name = _get_channel_name(channel_id)
if not channel_name:
flash("Impossible de trouver cette chaîne YouTube. Vérifiez l'ID de la chaîne.", "error")
return redirect(url_for("openYoutubeAlert"))
alert = YoutubeAlert(
enable=True,
channel_id=channel_id,
channel_name=channel_name,
notify_channel=request.form.get('notify_channel'),
message=request.form.get('message')
)
db.session.add(alert)
db.session.commit()
flash(f"Alerte ajoutée pour la chaîne {channel_name}.", "success")
return redirect(url_for("openYoutubeAlert"))
@webapp.route("/youtube-alert/toggle/<int:id>")
def toggleYoutubeAlert(id):
alert: YoutubeAlert = YoutubeAlert.query.get_or_404(id)
alert.enable = not alert.enable
db.session.commit()
return redirect(url_for("openYoutubeAlert"))
@webapp.route("/youtube-alert/edit/<int:id>")
def openEditYoutubeAlert(id):
alert = YoutubeAlert.query.get_or_404(id)
channels = bot.getAllTextChannel()
return render_template("youtube-alert.html", alert=alert, channels=channels)
@webapp.route("/youtube-alert/edit/<int:id>", methods=['POST'])
def submitEditYoutubeAlert(id):
alert: YoutubeAlert = YoutubeAlert.query.get_or_404(id)
new_channel_id = request.form.get('channel_id').strip()
if new_channel_id != alert.channel_id:
channel_name = _get_channel_name(new_channel_id)
if not channel_name:
flash("Impossible de trouver cette chaîne YouTube. Vérifiez l'ID de la chaîne.", "error")
return redirect(url_for("openEditYoutubeAlert", id=id))
alert.channel_id = new_channel_id
alert.channel_name = channel_name
alert.last_video_id = None
alert.notify_channel = request.form.get('notify_channel')
alert.message = request.form.get('message')
db.session.commit()
return redirect(url_for("openYoutubeAlert"))
@webapp.route("/youtube-alert/del/<int:id>")
def delYoutubeAlert(id):
alert = YoutubeAlert.query.get_or_404(id)
db.session.delete(alert)
db.session.commit()
return redirect(url_for("openYoutubeAlert"))
View File
-102
View File
@@ -1,102 +0,0 @@
import logging
import feedparser
from dataclasses import dataclass
from datetime import datetime
from database import db
from database.models import YoutubeAlert
from webapp import webapp
logger = logging.getLogger('youtube-alert')
logger.setLevel(logging.INFO)
YOUTUBE_RSS_URL = "https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
@dataclass
class YoutubeVideo:
video_id: str
title: str
author: str
link: str
published: datetime
thumbnail: str
def _fetch_latest_video(channel_id: str) -> YoutubeVideo | None:
feed_url = YOUTUBE_RSS_URL.format(channel_id=channel_id)
try:
feed = feedparser.parse(feed_url)
if feed.entries:
entry = feed.entries[0]
video_id = entry.yt_videoid
return YoutubeVideo(
video_id=video_id,
title=entry.title,
author=entry.author,
link=entry.link,
published=datetime(*entry.published_parsed[:6]),
thumbnail=f"https://i.ytimg.com/vi/{video_id}/maxresdefault.jpg"
)
except Exception as e:
logger.error(f"Erreur lors de la récupération du flux RSS pour {channel_id}: {e}")
return None
def _get_channel_name(channel_id: str) -> str | None:
feed_url = YOUTUBE_RSS_URL.format(channel_id=channel_id)
try:
feed = feedparser.parse(feed_url)
if feed.feed and hasattr(feed.feed, 'author'):
return feed.feed.author
if feed.entries:
return feed.entries[0].author
except Exception as e:
logger.error(f"Erreur lors de la récupération du nom de chaîne pour {channel_id}: {e}")
return None
async def checkNewVideos(bot):
with webapp.app_context():
alerts: list[YoutubeAlert] = YoutubeAlert.query.filter_by(enable=True).all()
for alert in alerts:
logger.info(f"Vérification de la chaîne : {alert.channel_name or alert.channel_id}")
video = _fetch_latest_video(alert.channel_id)
if video:
if not alert.channel_name:
alert.channel_name = video.author
if alert.last_video_id != video.video_id:
logger.info(f"Nouvelle vidéo détectée : {video.title}")
if alert.last_video_id is not None:
await _notifyAlert(bot, alert, video)
alert.last_video_id = video.video_id
alert.last_video_title = video.title
alert.last_video_date = video.published
else:
logger.warning(f"Impossible de récupérer les vidéos pour {alert.channel_id}")
db.session.commit()
async def _notifyAlert(bot, alert: YoutubeAlert, video: YoutubeVideo):
try:
message = alert.message.format(video)
logger.info(f"Message de notification : {message}")
bot.loop.create_task(_sendMessage(bot, alert.notify_channel, message))
except Exception as e:
logger.error(f"Erreur lors du formatage du message : {e}")
async def _sendMessage(bot, channel: int, message: str):
logger.info(f"Envoi de notification : {message}")
channel_obj = bot.get_channel(channel)
if channel_obj:
await channel_obj.send(message)
logger.info("Notification envoyée")
else:
logger.error(f"Canal Discord non trouvé : {channel}")