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>
This commit is contained in:
+202
-11
@@ -59,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])
|
||||
@@ -90,18 +90,209 @@ def _doPostImportMigration(cursor:Cursor):
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
);
|
||||
+144
-1
@@ -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,7 +83,17 @@ 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 TwitchAnnouncement(db.Model):
|
||||
__tablename__ = 'twitch_announcement'
|
||||
@@ -37,12 +105,14 @@ class TwitchAnnouncement(db.Model):
|
||||
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)
|
||||
@@ -55,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)
|
||||
@@ -84,3 +194,36 @@ class YouTubeNotification(db.Model):
|
||||
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)
|
||||
|
||||
|
||||
+103
-2
@@ -28,7 +28,16 @@ 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 `twitch_announcement` (
|
||||
@@ -46,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` (
|
||||
@@ -61,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,
|
||||
@@ -80,6 +99,40 @@ CREATE TABLE IF NOT EXISTS `member_invites` (
|
||||
`join_date` DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `twitch_link_filter` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`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 `twitch_allowed_domain` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`domain` VARCHAR(256) UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
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,
|
||||
@@ -97,3 +150,51 @@ CREATE TABLE IF NOT EXISTS `youtube_notification` (
|
||||
`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
|
||||
);
|
||||
|
||||
+24
-1
@@ -7,8 +7,9 @@ from webapp import webapp
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import Configuration, Humeur, Commande
|
||||
from discord import Message, TextChannel, Member
|
||||
from discord import Message, TextChannel, Member, VoiceChannel
|
||||
from discordbot.humblebundle import checkHumbleBundleAndNotify
|
||||
from discordbot.freeloot import checkFreeLootAndNotify
|
||||
from discordbot.moderation import (
|
||||
handle_warning_command,
|
||||
handle_remove_warning_command,
|
||||
@@ -24,6 +25,7 @@ from discordbot.moderation import (
|
||||
)
|
||||
from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
|
||||
from discordbot.youtube import checkYouTubeVideos
|
||||
from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms
|
||||
from protondb import searhProtonDb
|
||||
|
||||
class DiscordBot(discord.Client):
|
||||
@@ -40,6 +42,7 @@ class DiscordBot(discord.Client):
|
||||
self.loop.create_task(self.updateStatus())
|
||||
self.loop.create_task(self.updateHumbleBundle())
|
||||
self.loop.create_task(self.updateYouTube())
|
||||
self.loop.create_task(self.updateFreeLoot())
|
||||
|
||||
async def on_disconnect(self):
|
||||
webapp.config["BOT_STATUS"]["discord_connected"] = False
|
||||
@@ -64,6 +67,11 @@ class DiscordBot(discord.Client):
|
||||
await checkYouTubeVideos()
|
||||
await asyncio.sleep(5*60)
|
||||
|
||||
async def updateFreeLoot(self):
|
||||
while not self.is_closed():
|
||||
await checkFreeLootAndNotify(self)
|
||||
await asyncio.sleep(30*60)
|
||||
|
||||
def getAllTextChannel(self) -> list[TextChannel]:
|
||||
channels = []
|
||||
for channel in self.get_all_channels():
|
||||
@@ -71,6 +79,13 @@ class DiscordBot(discord.Client):
|
||||
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:
|
||||
@@ -261,6 +276,14 @@ 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)
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
# 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"),
|
||||
("📝", "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."""
|
||||
return "🔓" if access_mode == "open" else "🔒"
|
||||
|
||||
|
||||
def _build_control_embed(owner: Member, voice_channel: discord.VoiceChannel, access_mode: str) -> 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. "
|
||||
"Utilisez les réactions ci-dessous — seul le propriétaire peut réagir."
|
||||
),
|
||||
color=discord.Color.blurple()
|
||||
)
|
||||
members_count = len(voice_channel.members)
|
||||
user_limit = voice_channel.user_limit or 0
|
||||
limit_text = f"{user_limit} max" if user_limit else "Illimitée"
|
||||
members_text = f"{members_count} / {user_limit}" if user_limit else str(members_count)
|
||||
bitrate_kbps = (voice_channel.bitrate or 0) // 1000
|
||||
|
||||
embed.add_field(name="Propriétaire", value=owner.mention, inline=True)
|
||||
embed.add_field(name="Statut du salon", value=_status_display(access_mode), inline=True)
|
||||
embed.add_field(name="Nom du salon", value=voice_channel.name, inline=True)
|
||||
embed.add_field(name="Membres", value=members_text, inline=True)
|
||||
embed.add_field(name="Limite", value=limit_text, inline=True)
|
||||
embed.add_field(name="Bitrate", value=f"{bitrate_kbps} kbps", inline=True)
|
||||
embed.add_field(name="Accès", value="🔓 Ouvert · 🔒 Fermé · 🛡️ Privé", inline=False)
|
||||
embed.add_field(name="Listes", value="✅ Liste blanche · 🚫 Liste noire", inline=False)
|
||||
embed.add_field(name="Actions", value="🧹 Purge · 👑 Propriété · 🎤 Micro · 📹 Vidéo · 📝 Statut", inline=False)
|
||||
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 = {}
|
||||
everyone_ow = discord.PermissionOverwrite()
|
||||
if mode == "open":
|
||||
everyone_ow.connect = True
|
||||
everyone_ow.view_channel = True
|
||||
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
|
||||
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
|
||||
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()))
|
||||
# Mettre à jour le cadenas dans le nom du channel
|
||||
try:
|
||||
base_name = voice_channel.name.rstrip(" 🔓🔒")
|
||||
new_name = f"{base_name} {_status_emoji(action)}"
|
||||
await voice_channel.edit(name=new_name)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
await channel.send(f"Accès du salon défini sur **{action}**.")
|
||||
# Mettre à jour uniquement le statut (cadenas) dans le message de config
|
||||
control_message_id = room.get("control_message_id")
|
||||
if control_message_id:
|
||||
try:
|
||||
msg = await channel.fetch_message(control_message_id)
|
||||
if msg.embeds:
|
||||
embed = msg.embeds[0].copy()
|
||||
for i, f in enumerate(embed.fields):
|
||||
if f.name == "Statut du salon":
|
||||
embed.set_field_at(i, name="Statut du salon", value=_status_display(action), inline=f.inline)
|
||||
break
|
||||
else:
|
||||
embed.add_field(name="Statut du salon", value=_status_display(action), inline=False)
|
||||
await msg.edit(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
elif action == "whitelist":
|
||||
await channel.send("Liste blanche : mentionnez un membre pour l’ajouter/retirer.")
|
||||
|
||||
elif action == "blacklist":
|
||||
await channel.send("Liste noire : mentionnez un membre pour l’ajouter/retirer.")
|
||||
|
||||
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("Transférer le salon : mentionnez le membre à qui donner la propriété.")
|
||||
|
||||
elif action in ("speak", "stream"):
|
||||
everyone = voice_channel.guild.default_role
|
||||
overwrites = dict(voice_channel.overwrites)
|
||||
ow = overwrites.get(everyone) or discord.PermissionOverwrite()
|
||||
current = getattr(ow, action)
|
||||
setattr(ow, action, not current if current is not None else False)
|
||||
overwrites[everyone] = ow
|
||||
await voice_channel.edit(overwrites=overwrites)
|
||||
label = "Micro" if action == "speak" else "Vidéo"
|
||||
await channel.send(f"{label} : {'autorisé' if getattr(ow, action) else 'désactivé'} pour tous.")
|
||||
|
||||
elif action == "status":
|
||||
status_text = _status_display(room.get("access_mode", "open"))
|
||||
await channel.send(f"Statut du salon : {status_text}\nRépondez avec le nouveau nom du salon pour le modifier.")
|
||||
|
||||
|
||||
async def send_control_panel(bot: discord.Client, guild_id: int, owner: Member, voice_channel: discord.VoiceChannel) -> 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")
|
||||
|
||||
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 avec statut (cadenas) à la création
|
||||
channel_name = f"Salon de {member.display_name} {_status_emoji('open')}"
|
||||
try:
|
||||
new_channel = await guild.create_voice_channel(
|
||||
name=channel_name,
|
||||
category=category,
|
||||
reason="Auto room"
|
||||
)
|
||||
await member.move_to(new_channel)
|
||||
control_message_id = await send_control_panel(bot, guild.id, member, new_channel)
|
||||
_set_room(guild.id, member.id, {
|
||||
"guild_id": guild.id,
|
||||
"voice_channel_id": new_channel.id,
|
||||
"control_message_id": control_message_id,
|
||||
"owner_id": member.id,
|
||||
"whitelist": set(),
|
||||
"blacklist": set(),
|
||||
"access_mode": "open",
|
||||
})
|
||||
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_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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
+19
-2
@@ -10,23 +10,31 @@ 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)
|
||||
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):
|
||||
async def _checkChannelVideos(notification: YouTubeNotification, is_first_check: bool = False):
|
||||
try:
|
||||
channel_id = notification.channel_id
|
||||
|
||||
@@ -109,6 +117,15 @@ async def _checkChannelVideos(notification: YouTubeNotification):
|
||||
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()
|
||||
|
||||
@@ -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("&", "&")
|
||||
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
|
||||
@@ -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
|
||||
|
||||
+179
-7
@@ -6,28 +6,108 @@ from twitchAPI.type import AuthScope, ChatEvent
|
||||
from twitchAPI.chat import Chat, ChatEvent, ChatMessage, EventData
|
||||
|
||||
from database.helpers import ConfigurationHelper
|
||||
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]
|
||||
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)
|
||||
]
|
||||
|
||||
|
||||
async def _onReady(ready_event: EventData):
|
||||
logging.info('Bot Twitch prêt')
|
||||
channel = ConfigurationHelper().getValue('twitch_channel')
|
||||
webapp.config["BOT_STATUS"]["twitch_connected"] = True
|
||||
webapp.config["BOT_STATUS"]["twitch_channel_name"] = channel
|
||||
with webapp.app_context():
|
||||
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)
|
||||
# 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)
|
||||
|
||||
|
||||
async def _helloCommand(msg: ChatMessage):
|
||||
await msg.reply(f'Bonjour {msg.user.name}')
|
||||
@@ -43,6 +123,7 @@ def _isConfigured():
|
||||
|
||||
|
||||
class TwitchBot():
|
||||
_eventsub = None
|
||||
|
||||
async def _connect(self):
|
||||
with webapp.app_context():
|
||||
@@ -55,12 +136,55 @@ class TwitchBot():
|
||||
self.chat.register_event(ChatEvent.READY, _onReady)
|
||||
self.chat.register_event(ChatEvent.MESSAGE, _onMessage)
|
||||
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 : {e}')
|
||||
else:
|
||||
logging.info("Twitch n'est pas configuré")
|
||||
|
||||
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:
|
||||
@@ -70,13 +194,61 @@ class TwitchBot():
|
||||
await asyncio.sleep(5 * 60)
|
||||
|
||||
async def _runAnnouncements(self):
|
||||
channel = ConfigurationHelper().getValue('twitch_channel')
|
||||
with webapp.app_context():
|
||||
channel = ConfigurationHelper().getValue('twitch_channel')
|
||||
while True:
|
||||
try:
|
||||
await checkAndSendAnnouncements(self.chat, channel)
|
||||
await checkAndSendAnnouncements(self.chat, channel, self.twitch)
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur envoi annonces : {e}')
|
||||
await asyncio.sleep(60)
|
||||
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())
|
||||
|
||||
+98
-12
@@ -2,6 +2,7 @@ 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
|
||||
@@ -10,25 +11,110 @@ 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
|
||||
|
||||
async def checkAndSendAnnouncements(chat: Chat, channel: str):
|
||||
_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):
|
||||
"""
|
||||
Vérifie et envoie les annonces dont la périodicité est écoulée.
|
||||
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():
|
||||
announcements: list[TwitchAnnouncement] = TwitchAnnouncement.query.filter_by(enable=True).all()
|
||||
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()
|
||||
|
||||
for announcement in announcements:
|
||||
if _shouldSend(announcement, now):
|
||||
try:
|
||||
await _sendAnnouncement(chat, channel, announcement)
|
||||
announcement.last_sent = now
|
||||
db.session.commit()
|
||||
logger.info(f'Annonce envoyée : {announcement.name}')
|
||||
except Exception as e:
|
||||
logger.error(f'Erreur lors de l\'envoi de l\'annonce "{announcement.name}": {e}')
|
||||
# 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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
+45
-1
@@ -1,13 +1,57 @@
|
||||
import os
|
||||
from flask import Flask
|
||||
from flask_login import LoginManager
|
||||
|
||||
webapp = Flask(__name__)
|
||||
|
||||
# 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)
|
||||
}
|
||||
|
||||
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements
|
||||
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))
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
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'),
|
||||
@@ -26,7 +31,10 @@ def addAnnouncement():
|
||||
|
||||
|
||||
@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()
|
||||
@@ -34,13 +42,17 @@ def toggleAnnouncement(id):
|
||||
|
||||
|
||||
@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')
|
||||
@@ -51,7 +63,10 @@ def submitEditAnnouncement(id):
|
||||
|
||||
|
||||
@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()
|
||||
@@ -59,7 +74,10 @@ def delAnnouncement(id):
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
+164
@@ -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
@@ -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()
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
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
|
||||
|
||||
@webapp.route("/configurations")
|
||||
@require_page("configurations")
|
||||
def openConfigurations():
|
||||
return render_template("configurations.html", configuration = ConfigurationHelper(), channels = bot.getAllTextChannel(), roles = bot.getAllRoles())
|
||||
return render_template("configurations.html", configuration=ConfigurationHelper(), channels=bot.getAllTextChannel(), voice_channels=bot.getAllVoiceChannels(), roles=bot.getAllRoles())
|
||||
|
||||
@webapp.route("/configurations/update", methods=['POST'])
|
||||
@require_page("configurations")
|
||||
def updateConfiguration():
|
||||
checkboxes = {
|
||||
'humble_bundle_enable': 'humble_bundle_channel',
|
||||
@@ -17,7 +20,9 @@ def updateConfiguration():
|
||||
'moderation_ban_enable': 'moderation_staff_role_ids',
|
||||
'moderation_kick_enable': 'moderation_staff_role_ids',
|
||||
'welcome_enable': 'welcome_channel_id',
|
||||
'leave_enable': 'leave_channel_id'
|
||||
'leave_enable': 'leave_channel_id',
|
||||
'auto_rooms_enable': 'auto_rooms_channel_id',
|
||||
'twitch_commands_enable': 'twitch_channel'
|
||||
}
|
||||
|
||||
staff_roles = request.form.getlist('moderation_staff_role_ids')
|
||||
|
||||
@@ -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
@@ -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'))
|
||||
|
||||
+7
-1
@@ -1,11 +1,15 @@
|
||||
from flask import render_template
|
||||
from webapp import webapp
|
||||
from database.models import ModerationEvent
|
||||
from webapp.auth import require_page
|
||||
from database.models import ModerationEvent, TwitchAnnouncement, TwitchModerationLog
|
||||
|
||||
@webapp.route("/")
|
||||
@require_page("index")
|
||||
def index():
|
||||
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"],
|
||||
@@ -13,4 +17,6 @@ def index():
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
|
||||
@@ -30,6 +31,7 @@ def _top_moderators():
|
||||
)
|
||||
|
||||
@webapp.route("/moderation")
|
||||
@require_page("moderation")
|
||||
def moderation():
|
||||
events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
|
||||
top_sanctioned = _top_sanctioned()
|
||||
@@ -43,6 +45,7 @@ def moderation():
|
||||
)
|
||||
|
||||
@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()
|
||||
@@ -57,14 +60,20 @@ def open_edit_moderation_event(event_id):
|
||||
)
|
||||
|
||||
@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()
|
||||
|
||||
+11
-3
@@ -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'))
|
||||
|
||||
@@ -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"))
|
||||
@@ -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 %}
|
||||
+124
-49
@@ -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>
|
||||
</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 %}
|
||||
@@ -1,240 +1,341 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Configuration de Mamie</h1>
|
||||
<p>Configurez les tokens Discord, les notifications Humble Bundle et l'API Twitch.</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>
|
||||
|
||||
<h2>Discord</h2>
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
<fieldset>
|
||||
<legend>API Discord</legend>
|
||||
<label for="discord_token">Token Discord (caché)</label>
|
||||
<input name="discord_token" type="password" placeholder="Votre token Discord" />
|
||||
<small>Nécessite un redémarrage après modification</small>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Messages de bienvenue</legend>
|
||||
<label for="welcome_enable">
|
||||
<input type="checkbox" name="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked="checked"{% endif %}>
|
||||
Activer le message de bienvenue pour les nouveaux membres
|
||||
</label>
|
||||
|
||||
<label for="welcome_channel_id">Canal de bienvenue</label>
|
||||
<select name="welcome_channel_id">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('welcome_channel_id')==channel.id %}selected="selected"{% endif %}>
|
||||
{{channel.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="welcome_message">Message personnalisé de bienvenue</label>
|
||||
<textarea name="welcome_message" rows="3" placeholder="Bienvenue {member.mention} sur le serveur !">{{ configuration.getValue('welcome_message') }}</textarea>
|
||||
<small>
|
||||
<strong>Syntaxes disponibles :</strong><br>
|
||||
• <code>{member.mention}</code> - Mentionne l'utilisateur (@NomUtilisateur)<br>
|
||||
• <code>{member.name}</code> - Nom d'utilisateur (sans mention)<br>
|
||||
• <code>{member.display_name}</code> - Surnom sur le serveur<br>
|
||||
• <code>{member.id}</code> - ID de l'utilisateur<br>
|
||||
• <code>{server.name}</code> - Nom du serveur<br>
|
||||
• <code>{server.member_count}</code> - Nombre total de membres<br>
|
||||
• <code><#ID_DU_CHANNEL></code> - Mentionne un salon (ex: <#123456789012345678>)
|
||||
</small>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Messages de départ</legend>
|
||||
<label for="leave_enable">
|
||||
<input type="checkbox" name="leave_enable" {% if configuration.getValue('leave_enable') %}checked="checked"{% endif %}>
|
||||
Activer le message de départ quand un membre quitte le serveur
|
||||
</label>
|
||||
|
||||
<label for="leave_channel_id">Canal de départ</label>
|
||||
<select name="leave_channel_id">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('leave_channel_id')==channel.id %}selected="selected"{% endif %}>
|
||||
{{channel.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="leave_message">Message personnalisé de départ</label>
|
||||
<textarea name="leave_message" rows="3" placeholder="{member.mention} a quitté le serveur.">{{ configuration.getValue('leave_message') }}</textarea>
|
||||
<small>
|
||||
<strong>Syntaxes disponibles :</strong><br>
|
||||
• <code>{member.mention}</code> - Mentionne l'utilisateur (@NomUtilisateur)<br>
|
||||
• <code>{member.name}</code> - Nom d'utilisateur (sans mention)<br>
|
||||
• <code>{member.display_name}</code> - Surnom sur le serveur<br>
|
||||
• <code>{member.id}</code> - ID de l'utilisateur<br>
|
||||
• <code>{server.name}</code> - Nom du serveur<br>
|
||||
• <code>{server.member_count}</code> - Nombre total de membres<br>
|
||||
• <code><#ID_DU_CHANNEL></code> - Mentionne un salon (ex: <#123456789012345678>)
|
||||
</small>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Modération</legend>
|
||||
<label for="moderation_enable">
|
||||
<input type="checkbox" name="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked="checked"{% endif %}>
|
||||
Activer les commandes d'avertissement (!warn, !unwarn, !inspect)
|
||||
</label>
|
||||
|
||||
<label for="moderation_ban_enable">
|
||||
<input type="checkbox" name="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked="checked"{% endif %}>
|
||||
Activer les commandes de bannissement (!ban, !unban)
|
||||
</label>
|
||||
|
||||
<label for="moderation_kick_enable">
|
||||
<input type="checkbox" name="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked="checked"{% endif %}>
|
||||
Activer la commande d'expulsion (!kick)
|
||||
</label>
|
||||
|
||||
<label for="moderation_log_channel_id">Canal de logs de modération</label>
|
||||
<select name="moderation_log_channel_id">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('moderation_log_channel_id')==channel.id %}selected="selected"{% endif %}>
|
||||
{{channel.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>Toutes les actions de modération seront notifiées dans ce canal</small>
|
||||
|
||||
<label>Rôles Staff autorisés</label>
|
||||
{% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %}
|
||||
|
||||
{% if roles|length > 1 %}
|
||||
<div class="tabs">
|
||||
{% for guild_data in roles %}
|
||||
<button type="button" class="tab-button" onclick="openTab(event, 'guild-{{guild_data.guild_id}}')" {% if loop.first %}id="defaultOpen"{% endif %}>
|
||||
{{ guild_data.guild_name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
<div 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>
|
||||
{% endif %}
|
||||
|
||||
{% for guild_data in roles %}
|
||||
<div id="guild-{{guild_data.guild_id}}" class="tab-content" {% if not loop.first %}style="display: none;"{% endif %}>
|
||||
<div style="max-height: 300px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; border-radius: 5px;">
|
||||
{% for role in guild_data.roles %}
|
||||
<label style="display: block; margin: 5px 0;">
|
||||
<input type="checkbox" name="moderation_staff_role_ids" value="{{role.id}}" {% if role.id|string in selected_roles %}checked="checked"{% endif %}>
|
||||
{% if role.color.value != 0 %}
|
||||
<span style="color:#{{ '%06x' % role.color.value }}">●</span>
|
||||
{% else %}
|
||||
<span>○</span>
|
||||
{% endif %}
|
||||
{{role.name}}
|
||||
</label>
|
||||
{% endfor %}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<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 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="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="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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for guild_data in roles %}
|
||||
<div id="guild-{{guild_data.guild_id}}" class="tab-content {% if not loop.first %}hidden{% endif %}">
|
||||
<div class="max-h-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 %}
|
||||
<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 %}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{% endfor %}
|
||||
|
||||
<small>Sélectionnez un ou plusieurs rôles qui peuvent utiliser les commandes de modération</small>
|
||||
<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>
|
||||
|
||||
<script>
|
||||
function openTab(evt, tabName) {
|
||||
var i, tabcontent, tabbuttons;
|
||||
tabcontent = document.getElementsByClassName("tab-content");
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].style.display = "none";
|
||||
}
|
||||
tabbuttons = document.getElementsByClassName("tab-button");
|
||||
for (i = 0; i < tabbuttons.length; i++) {
|
||||
tabbuttons[i].className = tabbuttons[i].className.replace(" active", "");
|
||||
}
|
||||
document.getElementById(tabName).style.display = "block";
|
||||
evt.currentTarget.className += " active";
|
||||
}
|
||||
document.getElementById("defaultOpen")?.click();
|
||||
</script>
|
||||
<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>
|
||||
|
||||
<style>
|
||||
.tabs {
|
||||
overflow: hidden;
|
||||
border-bottom: 2px solid #ccc;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tab-button {
|
||||
background-color: #f1f1f1;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 20px;
|
||||
transition: 0.3s;
|
||||
font-size: 14px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.tab-button:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
.tab-button.active {
|
||||
background-color: #ccc;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tab-content {
|
||||
animation: fadeEffect 0.3s;
|
||||
}
|
||||
@keyframes fadeEffect {
|
||||
from {opacity: 0;}
|
||||
to {opacity: 1;}
|
||||
}
|
||||
</style>
|
||||
<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="moderation_embed_delete_delay">Délai de suppression des embeds (en secondes)</label>
|
||||
<input name="moderation_embed_delete_delay" type="number" value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" placeholder="0" min="0" />
|
||||
<small>Mettre 0 pour ne pas supprimer automatiquement</small>
|
||||
</fieldset>
|
||||
<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>
|
||||
|
||||
<input type="Submit" value="Enregistrer la configuration Discord">
|
||||
</form>
|
||||
<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>
|
||||
|
||||
<h2>API Twitch</h2>
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
<label for="twitch_client_id">Client ID</label>
|
||||
<input name="twitch_client_id" type="text" value="{{ configuration.getValue('twitch_client_id') }}" />
|
||||
<label for="twitch_client_secret">Client Secret</label>
|
||||
<input name="twitch_client_secret" type="text" value="{{ configuration.getValue('twitch_client_secret') }}" />
|
||||
<label for="twitch_channel">Chaîne à rejoindre</label>
|
||||
<input name="twitch_channel" type="text" value="{{ configuration.getValue('twitch_channel') }}"
|
||||
placeholder="#machinTruc" />
|
||||
<input type="Submit" value="Enregistrer la configuration Twitch">
|
||||
<p>
|
||||
<a href="{{ url_for('twitchConfigurationHelp') }}">Aide</a>
|
||||
</p>
|
||||
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
|
||||
<p>
|
||||
<a href="{{ url_for('twitchRequestToken') }}">Obtenir token et refresh token</a>
|
||||
</p>
|
||||
<label for="twitch_access_token">Access Token</label>
|
||||
<input name="twitch_access_token" type="text" value="{{ configuration.getValue('twitch_access_token') }}"
|
||||
readonly="readonly" />
|
||||
<label for="twitch_refresh_token">Refresh Token</label>
|
||||
<input name="twitch_refresh_token" type="text" value="{{ configuration.getValue('twitch_refresh_token') }}"
|
||||
readonly="readonly" />
|
||||
<p>Nécessite un redémarrage après l'obtention des Tokens.</p>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% 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>
|
||||
|
||||
<h2>Humble Bundle</h2>
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
<p>Humble Bundle propose régulièrement des bundles de jeux vidéo à des prix réduits. Activez les notifications pour recevoir automatiquement les nouveaux packs disponibles sur votre serveur Discord.</p>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<label for="humble_bundle_enable">
|
||||
<input type="checkbox" name="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked="checked"{% endif %}>
|
||||
Activer les notifications Humble Bundle
|
||||
</label>
|
||||
<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>
|
||||
|
||||
<label for="humble_bundle_channel">Canal de notification</label>
|
||||
<select name="humble_bundle_channel">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('humble_bundle_channel')==channel.id %}selected="selected"{% endif %}>
|
||||
{{channel.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<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>
|
||||
|
||||
<input type="Submit" value="Enregistrer la configuration Humble Bundle">
|
||||
</form>
|
||||
<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>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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>
|
||||
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 %}
|
||||
@@ -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 l’embed).</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 l’embed 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 n’a pas pu être chargé. Réessayez plus tard.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,29 +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>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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 %}
|
||||
@@ -10,7 +10,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Zone Discord #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden mb-8">
|
||||
<div class="p-4 sm:p-6 border-b border-slate-200 dark:border-slate-700 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -43,7 +42,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Zone Twitch #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden mb-8">
|
||||
<div class="p-4 sm:p-6 border-b border-slate-200 dark:border-slate-700 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -60,12 +58,19 @@
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{% if twitch_channel_name %}{{ twitch_channel_name }}{% else %}—{% endif %}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Sanctions</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">—</p>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">À venir</p>
|
||||
<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 sm:col-span-2 lg:col-span-2 flex items-center justify-center">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">Intégrations du bot Twitch à venir.</p>
|
||||
<div 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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
<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 %}
|
||||
@@ -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 %}
|
||||
+124
-50
@@ -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>
|
||||
<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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -94,6 +94,10 @@
|
||||
<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
|
||||
@@ -102,6 +106,10 @@
|
||||
<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>
|
||||
@@ -111,6 +119,10 @@
|
||||
<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>
|
||||
@@ -128,10 +140,22 @@
|
||||
<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>
|
||||
@@ -141,10 +165,29 @@
|
||||
<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>
|
||||
@@ -170,6 +213,18 @@
|
||||
<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
|
||||
@@ -186,14 +241,41 @@
|
||||
<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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
+238
-141
@@ -1,153 +1,264 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Notifications YouTube</h1>
|
||||
<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="alert alert-{{ msg_type }}" style="padding: 10px; margin: 10px 0; border: 1px solid {{ '#f00' if msg_type == 'error' else '#0f0' }}; background-color: {{ '#ffe0e0' if msg_type == 'error' else '#e0ffe0' }};">
|
||||
{{ msg }}
|
||||
{% 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>
|
||||
<script>
|
||||
setTimeout(function() {
|
||||
var el = document.getElementById('alert-msg');
|
||||
if (el) el.style.display = 'none';
|
||||
}, 5000);
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
Liste des chaînes YouTube surveillées pour les notifications de nouvelles vidéos.
|
||||
|
||||
Le bot vérifie toutes les 5 minutes les nouvelles vidéos des chaînes en dessous.
|
||||
Quand une nouvelle vidéo est détectée, le bot enverra une notification sur Discord.
|
||||
</p>
|
||||
|
||||
{% if not notification %}
|
||||
<h2>Notifications</h2>
|
||||
<table class="live-alert">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chaîne YouTube</th>
|
||||
<th>Canal Discord</th>
|
||||
<th>Type</th>
|
||||
<th>Message</th>
|
||||
<th>#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for notification in notifications %}
|
||||
<tr>
|
||||
<td>{{notification.channel_id}}</td>
|
||||
<td>{{notification.notify_channel_name}}</td>
|
||||
<td>
|
||||
{% if notification.video_type == 'all' %}
|
||||
Toutes
|
||||
{% elif notification.video_type == 'video' %}
|
||||
Vidéos uniquement
|
||||
{% elif notification.video_type == 'short' %}
|
||||
Shorts uniquement
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{notification.message}}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('toggleYouTube', id = notification.id) }}" class="icon">{{ '✅' if notification.enable else '❌' }}</a>
|
||||
<a href="{{ url_for('openEditYouTube', id = notification.id) }}" class="icon">✐</a>
|
||||
<a href="{{ url_for('delYouTube', id = notification.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette notification ?')" class="icon">🗑</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<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 %}
|
||||
|
||||
<h2>{{ 'Editer une notification' if notification else 'Ajouter une notification YouTube' }}</h2>
|
||||
<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 style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||
<div>
|
||||
<form id="youtube-form" action="{{ url_for('submitEditYouTube', id = notification.id) if notification else url_for('addYouTube') }}" method="POST">
|
||||
<fieldset>
|
||||
<legend>Configuration de base</legend>
|
||||
<label for="channel_id">Lien ou ID de la chaîne YouTube</label>
|
||||
<input name="channel_id" id="channel_id" type="text" maxlength="256" required="required" value="{{notification.channel_id if notification}}" placeholder="https://www.youtube.com/@513v3 ou https://www.youtube.com/channel/UC... ou UC..."/>
|
||||
<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>
|
||||
|
||||
<label for="notify_channel">Canal de Notification Discord</label>
|
||||
<select name="notify_channel" id="notify_channel">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}"{% if notification and notification.notify_channel == channel.id %}
|
||||
selected="selected" {% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<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>
|
||||
|
||||
<label for="video_type">Type de vidéo à notifier</label>
|
||||
<select name="video_type" id="video_type">
|
||||
<option value="all"{% if notification and notification.video_type == 'all' %} selected="selected" {% endif %}>Toutes (vidéos + shorts)</option>
|
||||
<option value="video"{% if notification and notification.video_type == 'video' %} selected="selected" {% endif %}>Vidéos uniquement</option>
|
||||
<option value="short"{% if notification and notification.video_type == 'short' %} selected="selected" {% endif %}>Shorts uniquement</option>
|
||||
</select>
|
||||
<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>
|
||||
|
||||
<label for="message">Message (optionnel, envoyé avant l'embed)</label>
|
||||
<textarea name="message" id="message" rows="3" cols="50">{{notification.message if notification}}</textarea>
|
||||
</fieldset>
|
||||
<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>
|
||||
|
||||
<fieldset>
|
||||
<legend>Personnalisation de l'embed Discord</legend>
|
||||
<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>
|
||||
|
||||
<label for="embed_title">Titre de l'embed</label>
|
||||
<input name="embed_title" id="embed_title" type="text" maxlength="256" value="{{notification.embed_title if notification}}" placeholder="{video_title} (par défaut: titre de la vidéo)"/>
|
||||
<small>Variables: {video_title}, {channel_name}, {video_url}, {video_id}</small>
|
||||
<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>
|
||||
|
||||
<label for="embed_description">Description de l'embed</label>
|
||||
<textarea name="embed_description" id="embed_description" rows="4" cols="50" placeholder="Description optionnelle de l'embed">{{notification.embed_description if notification}}</textarea>
|
||||
<small>Variables: {video_title}, {channel_name}, {video_url}, {published_at}, {is_short}</small>
|
||||
<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>
|
||||
|
||||
<label for="embed_color">Couleur de l'embed (hexadécimal)</label>
|
||||
<input name="embed_color" id="embed_color" type="color" value="#{{notification.embed_color if notification else 'FF0000'}}" style="width: 100px; height: 40px;"/>
|
||||
<input type="text" id="embed_color_text" value="{{notification.embed_color if notification else 'FF0000'}}" placeholder="FF0000" style="width: 100px; margin-left: 10px;" maxlength="6"/>
|
||||
<small>Format: FF0000 (rouge YouTube par défaut)</small>
|
||||
<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>
|
||||
|
||||
<label for="embed_author_name">Nom de l'auteur</label>
|
||||
<input name="embed_author_name" id="embed_author_name" type="text" maxlength="256" value="{{notification.embed_author_name if notification}}" placeholder="{channel_name} (par défaut: nom de la chaîne)"/>
|
||||
<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>
|
||||
|
||||
<label for="embed_author_icon">Icône de l'auteur (URL)</label>
|
||||
<input name="embed_author_icon" id="embed_author_icon" type="text" maxlength="512" value="{{notification.embed_author_icon if notification}}" placeholder="https://www.youtube.com/img/desktop/yt_1200.png"/>
|
||||
<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>
|
||||
|
||||
<label for="embed_footer">Pied de page</label>
|
||||
<input name="embed_footer" id="embed_footer" type="text" maxlength="2048" value="{{notification.embed_footer if notification}}" placeholder="Texte optionnel en bas de l'embed"/>
|
||||
<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>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="embed_thumbnail" id="embed_thumbnail" {% if not notification or notification.embed_thumbnail %}checked="checked"{% endif %}>
|
||||
Afficher la miniature (thumbnail) en haut à droite
|
||||
</label>
|
||||
<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>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="embed_image" id="embed_image" {% if not notification or notification.embed_image %}checked="checked"{% endif %}>
|
||||
Afficher l'image principale (image de la vidéo)
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<input type="Submit" value="{{ 'Modifier' if notification else 'Ajouter' }}">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Prévisualisation de l'embed Discord</h3>
|
||||
<div id="embed-preview" style="background-color: #2f3136; border-radius: 4px; padding: 16px; font-family: 'Whitney', 'Helvetica Neue', Helvetica, Arial, sans-serif; color: #dcddde; max-width: 520px; border-left: 4px solid #FF0000;">
|
||||
<div id="embed-author" style="display: flex; align-items: center; margin-bottom: 8px; font-size: 14px;">
|
||||
<img id="embed-author-icon" src="https://www.youtube.com/img/desktop/yt_1200.png" style="width: 20px; height: 20px; border-radius: 50%; margin-right: 8px;" onerror="this.style.display='none'"/>
|
||||
<span id="embed-author-name" style="font-weight: 600;">Nom de la chaîne</span>
|
||||
</div>
|
||||
<a id="embed-title" href="#" style="color: #00aff4; text-decoration: none; font-size: 16px; font-weight: 600; display: block; margin-bottom: 8px;">Titre de la vidéo</a>
|
||||
<div id="embed-description" style="font-size: 14px; line-height: 1.375; margin-bottom: 8px; color: #dcddde;"></div>
|
||||
<div id="embed-thumbnail-container" style="margin: 8px 0;">
|
||||
<img id="embed-thumbnail" src="" style="max-width: 80px; max-height: 80px; border-radius: 4px; float: right; margin-left: 16px; display: none;"/>
|
||||
</div>
|
||||
<div id="embed-image-container" style="margin-top: 16px;">
|
||||
<img id="embed-image" src="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" style="max-width: 100%; border-radius: 4px; display: none;"/>
|
||||
</div>
|
||||
<div id="embed-footer" style="margin-top: 8px; font-size: 12px; color: #72767d;"></div>
|
||||
<div 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('youtube') }}"
|
||||
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>
|
||||
<small style="color: #666;">Cette prévisualisation est approximative. L'apparence réelle sur Discord peut varier légèrement.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -228,18 +339,4 @@
|
||||
|
||||
updatePreview();
|
||||
</script>
|
||||
|
||||
<p>
|
||||
<strong>Variables disponibles pour l'embed :</strong>
|
||||
<ul>
|
||||
<li><code>{channel_name}</code> : nom de la chaîne YouTube</li>
|
||||
<li><code>{video_title}</code> : titre de la vidéo</li>
|
||||
<li><code>{video_url}</code> : lien vers la vidéo</li>
|
||||
<li><code>{video_id}</code> : ID de la vidéo</li>
|
||||
<li><code>{thumbnail}</code> : URL de la miniature</li>
|
||||
<li><code>{published_at}</code> : date de publication</li>
|
||||
<li><code>{is_short}</code> : True si c'est un short, False sinon</li>
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
+33
-12
@@ -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")
|
||||
@require_page("configurations")
|
||||
def twitchConfigurationHelp():
|
||||
return render_template("twitch-aide.html", token_redirect_url = _buildUrl())
|
||||
return render_template("twitch-aide.html", token_redirect_url=_buildUrl())
|
||||
|
||||
|
||||
@webapp.route("/configurations/twitch/request-token")
|
||||
async def twitchRequestToken():
|
||||
@require_page("configurations")
|
||||
def twitchRequestToken():
|
||||
global auth
|
||||
helper = ConfigurationHelper()
|
||||
twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
|
||||
twitch = asyncio.run(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():
|
||||
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é
|
||||
|
||||
@@ -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"))
|
||||
@@ -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
@@ -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"))
|
||||
+15
-1
@@ -2,8 +2,8 @@ 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
|
||||
@@ -64,6 +64,7 @@ def _get_channel_id_from_handle(handle: str) -> str:
|
||||
|
||||
|
||||
@webapp.route("/youtube")
|
||||
@require_page("youtube")
|
||||
def openYouTube():
|
||||
notifications: list[YouTubeNotification] = YouTubeNotification.query.all()
|
||||
channels = bot.getAllTextChannel()
|
||||
@@ -77,7 +78,10 @@ def openYouTube():
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
@@ -118,7 +122,10 @@ def addYouTube():
|
||||
|
||||
|
||||
@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()
|
||||
@@ -126,6 +133,7 @@ def toggleYouTube(id):
|
||||
|
||||
|
||||
@webapp.route("/youtube/edit/<int:id>")
|
||||
@require_page("youtube")
|
||||
def openEditYouTube(id):
|
||||
notification = YouTubeNotification.query.get_or_404(id)
|
||||
channels = bot.getAllTextChannel()
|
||||
@@ -135,7 +143,10 @@ def openEditYouTube(id):
|
||||
|
||||
|
||||
@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()
|
||||
@@ -174,7 +185,10 @@ def submitEditYouTube(id):
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
Reference in New Issue
Block a user