Merge branch 'twitchbot' into main

Intégration de toutes les fonctionnalités développées dans la branche twitchbot :
- Système de modération Twitch complet
- Filtre de liens intelligent
- Notifications d'événements Twitch
- Système Freeloot Discord
- Salons automatiques Discord
- Authentification utilisateur
- Gestion des utilisateurs
- Interfaces d'administration étendues

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-12 22:40:27 +01:00
co-authored by Cursor
54 changed files with 8764 additions and 745 deletions
+228 -12
View File
@@ -33,7 +33,13 @@ def _set_sqlite_pragma(dbapi_connection, connection_record):
except Exception:
pass
def _tableHaveColumn(table_name:str, column_name:str, cursor:Cursor) -> bool:
def _tableExists(table_name: str, cursor: Cursor) -> bool:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
return cursor.fetchone() is not None
def _tableHaveColumn(table_name:str, column_name:str, cursor:Cursor) -> bool:
if not _tableExists(table_name, cursor):
return False
cursor.execute(f'PRAGMA table_info({table_name})')
columns = cursor.fetchall()
return any(col[1] == column_name for col in columns)
@@ -53,9 +59,9 @@ def _doPreImportMigration(cursor:Cursor):
_renameTable('game_bundle', 'game_bundle_old', cursor)
def _doPostImportMigration(cursor:Cursor):
if _tableEmpty('game_bundle', cursor) :
if _tableEmpty('game_bundle', cursor) and _tableExists('game_bundle_old', cursor):
logging.info("remplir game_bundle avec game_bundle_old")
bundles = cursor.execute(f'SELECT * FROM game_bundle_old').fetchall()
bundles = cursor.execute('SELECT * FROM game_bundle_old').fetchall()
for bundle in bundles :
name = bundle[1]
json_data = json.loads(bundle[2])
@@ -65,18 +71,228 @@ def _doPostImportMigration(cursor:Cursor):
logging.info("suppression de la table temporaire game_bundle_old")
_dropTable('game_bundle_old', cursor)
if _tableExists('youtube_notification', cursor):
embed_columns = [
('embed_title', 'VARCHAR(256)'),
('embed_description', 'VARCHAR(2000)'),
('embed_color', 'VARCHAR(8) DEFAULT "FF0000"'),
('embed_footer', 'VARCHAR(2048)'),
('embed_author_name', 'VARCHAR(256)'),
('embed_author_icon', 'VARCHAR(512)'),
('embed_thumbnail', 'BOOLEAN DEFAULT 1'),
('embed_image', 'BOOLEAN DEFAULT 1'),
]
for col_name, col_type in embed_columns:
if not _tableHaveColumn('youtube_notification', col_name, cursor):
try:
cursor.execute(f'ALTER TABLE youtube_notification ADD COLUMN {col_name} {col_type}')
logging.info(f"Colonne {col_name} ajoutée à youtube_notification")
except Exception as e:
logging.warning(f"Colonne youtube_notification.{col_name}: {e}")
if _tableExists('commande', cursor) and not _tableHaveColumn('commande', 'twitch_permission', cursor):
try:
cursor.execute("ALTER TABLE commande ADD COLUMN twitch_permission VARCHAR(16) DEFAULT 'viewer'")
logging.info("Colonne twitch_permission ajoutée à commande")
except Exception as e:
logging.warning(f"Colonne commande.twitch_permission: {e}")
def _doAddColumnMigrations(cursor: Cursor):
"""Migrations d'ajout de colonnes. Exécutées à part pour ne pas dépendre du script principal."""
if _tableExists('commande', cursor) and not _tableHaveColumn('commande', 'twitch_permission', cursor):
try:
cursor.execute("ALTER TABLE commande ADD COLUMN twitch_permission VARCHAR(16) DEFAULT 'viewer'")
logging.info("Colonne twitch_permission ajoutée à commande")
except Exception as e:
logging.warning(f"Colonne commande.twitch_permission: {e}")
if _tableExists('live_alert', cursor) and not _tableHaveColumn('live_alert', 'watch_activity', cursor):
try:
cursor.execute("ALTER TABLE live_alert ADD COLUMN watch_activity BOOLEAN NOT NULL DEFAULT 0")
logging.info("Colonne watch_activity ajoutée à live_alert")
except Exception as e:
logging.warning(f"Colonne live_alert.watch_activity: {e}")
# Colonnes embed pour live_alert (message par défaut en embed)
if _tableExists('live_alert', cursor):
live_alert_embed_columns = [
('embed_title', 'VARCHAR(256)'),
('embed_description', 'VARCHAR(2000)'),
('embed_color', 'VARCHAR(8) DEFAULT "9146FF"'),
('embed_footer', 'VARCHAR(2048)'),
('embed_author_name', 'VARCHAR(256)'),
('embed_author_icon', 'VARCHAR(512)'),
('embed_thumbnail', 'BOOLEAN DEFAULT 1'),
('embed_image', 'BOOLEAN DEFAULT 1'),
]
for col_name, col_type in live_alert_embed_columns:
if not _tableHaveColumn('live_alert', col_name, cursor):
try:
cursor.execute(f'ALTER TABLE live_alert ADD COLUMN {col_name} {col_type}')
logging.info(f"Colonne {col_name} ajoutée à live_alert")
except Exception as e:
logging.warning(f"Colonne live_alert.{col_name}: {e}")
# Table twitch_event_notification + seed des 4 types
if not _tableExists('twitch_event_notification', cursor):
try:
cursor.execute("""
CREATE TABLE twitch_event_notification (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type VARCHAR(32) UNIQUE NOT NULL,
enable BOOLEAN NOT NULL DEFAULT 1,
notify_twitch_chat BOOLEAN NOT NULL DEFAULT 1,
notify_discord BOOLEAN NOT NULL DEFAULT 0,
discord_channel_id INTEGER NULL,
message_twitch VARCHAR(500) NOT NULL DEFAULT '',
message_discord VARCHAR(2000) NULL,
embed_color VARCHAR(8) DEFAULT '9146FF',
embed_title VARCHAR(256) NULL,
embed_description VARCHAR(2000) NULL,
embed_thumbnail BOOLEAN NOT NULL DEFAULT 1,
last_clip_id VARCHAR(128) NULL
)
""")
logging.info("Table twitch_event_notification créée")
except Exception as e:
logging.warning(f"Table twitch_event_notification: {e}")
if _tableExists('twitch_event_notification', cursor) and _tableEmpty('twitch_event_notification', cursor):
for ev in ('sub', 'follow', 'raid', 'clip'):
try:
cursor.execute(
"INSERT INTO twitch_event_notification (event_type, message_twitch) VALUES (?, ?)",
(ev, 'Merci {user} !' if ev != 'raid' else 'Bienvenue aux viewers de {from_broadcaster_name} !'),
)
except Exception as e:
logging.warning(f"Seed twitch_event_notification {ev}: {e}")
# Table webapp_user (auth)
if not _tableExists('webapp_user', cursor):
try:
cursor.execute("""
CREATE TABLE webapp_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(64) UNIQUE NOT NULL,
email VARCHAR(256) UNIQUE NOT NULL,
password_hash VARCHAR(256) NOT NULL,
role VARCHAR(64) NOT NULL DEFAULT 'viewer_twitch',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
logging.info("Table webapp_user créée")
except Exception as e:
logging.warning(f"Table webapp_user: {e}")
# Tables webapp_role et webapp_page_permission
if not _tableExists('webapp_role', cursor):
try:
cursor.execute("""
CREATE TABLE webapp_role (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(64) UNIQUE NOT NULL,
level INTEGER NOT NULL DEFAULT 0
)
""")
logging.info("Table webapp_role créée")
except Exception as e:
logging.warning(f"Table webapp_role: {e}")
if not _tableExists('webapp_page_permission', cursor):
try:
cursor.execute("""
CREATE TABLE webapp_page_permission (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_key VARCHAR(64) UNIQUE NOT NULL,
min_level INTEGER NOT NULL DEFAULT 0,
write_level INTEGER NULL
)
""")
logging.info("Table webapp_page_permission créée")
except Exception as e:
logging.warning(f"Table webapp_page_permission: {e}")
def _doSeedAuth(cursor: Cursor):
"""Seed rôles par défaut et permissions des pages si vides."""
from database.models import ROLE_ORDER
if not _tableExists('webapp_role', cursor):
return
if cursor.execute("SELECT COUNT(*) FROM webapp_role").fetchone()[0] > 0:
return
default_roles = [
("viewer_twitch", 0),
("utilisateur_discord", 1),
("moderateur_discord", 2),
("expert_discord", 3),
("moderateur_twitch", 4),
("super_administrateur", 5),
]
for name, level in default_roles:
try:
cursor.execute("INSERT INTO webapp_role (name, level) VALUES (?, ?)", (name, level))
except Exception as e:
logging.warning(f"Seed role {name}: {e}")
logging.info("Rôles par défaut insérés")
if not _tableExists('webapp_page_permission', cursor):
return
if cursor.execute("SELECT COUNT(*) FROM webapp_page_permission").fetchone()[0] > 0:
return
# page_key, min_level, write_level (NULL = même que min_level)
default_pages = [
("index", 0, None),
("configurations", 5, 5),
("commandes", 1, 2),
("humeurs", 1, 2),
("live_alert", 0, 4),
("announcements", 0, 4),
("twitch_moderation", 0, 4),
("link_filter", 0, 4),
("twitch_events", 0, 4),
("youtube", 1, 2),
("protondb", 1, 2),
("freeloot", 1, 2),
("moderation", 1, 2),
("users", 5, 5),
("settings", 5, 5),
]
for page_key, min_level, wl in default_pages:
try:
cursor.execute(
"INSERT INTO webapp_page_permission (page_key, min_level, write_level) VALUES (?, ?, ?)",
(page_key, min_level, wl),
)
except Exception as e:
logging.warning(f"Seed page {page_key}: {e}")
logging.info("Permissions pages par défaut insérées")
# Inscriptions activées par défaut
if _tableExists("configuration", cursor):
try:
cursor.execute(
"INSERT OR IGNORE INTO configuration (key, value) VALUES ('registration_enabled', 'true')"
)
except Exception as e:
logging.warning(f"Config registration_enabled: {e}")
with webapp.app_context():
with open('database/schema.sql', 'r') as f:
sql = f.read()
connection : Connection = db.session.connection().connection
connection: Connection = db.session.connection().connection
try:
cursor = connection.cursor()
_doPreImportMigration(cursor)
cursor.executescript(sql)
_doPostImportMigration(cursor)
connection.commit()
except Exception as e:
logging.error(f"lors de l'import de la bdd : {e}")
finally:
try:
cursor : Cursor = connection.cursor()
_doPreImportMigration(cursor)
cursor.executescript(sql)
_doPostImportMigration(cursor)
cursor = connection.cursor()
_doAddColumnMigrations(cursor)
_doSeedAuth(cursor)
connection.commit()
cursor.close()
except Exception as e:
logging.error(f"lors de l'import de la bdd : {e}")
finally:
connection.close()
logging.warning(f"migrations colonnes : {e}")
connection.close()
+11
View File
@@ -0,0 +1,11 @@
-- Migration: Ajout de la table twitch_banned_word
-- Date: 2026-02-10
-- Description: Permet de gérer les mots interdits dans le chat Twitch
CREATE TABLE IF NOT EXISTS `twitch_banned_word` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`word` VARCHAR(256) UNIQUE NOT NULL,
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
`timeout_duration` INTEGER NOT NULL DEFAULT 60,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+171 -5
View File
@@ -1,4 +1,62 @@
from datetime import datetime
from database import db
from flask_login import UserMixin
# Rôles par défaut (niveau 0 à 5) — seed en base via migration
ROLE_ORDER = [
"viewer_twitch",
"utilisateur_discord",
"moderateur_discord",
"expert_discord",
"moderateur_twitch",
"super_administrateur",
]
def role_level(role_name: str) -> int:
"""Retourne le niveau du rôle depuis la table webapp_role (-1 si inconnu)."""
if not role_name:
return -1
r = WebappRole.query.filter_by(name=role_name).first()
return r.level if r else -1
class WebappRole(db.Model):
__tablename__ = "webapp_role"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True, nullable=False)
level = db.Column(db.Integer, nullable=False, default=0)
description = db.Column(db.String(256), nullable=True)
color = db.Column(db.String(7), nullable=True, default="#6B7280") # Couleur hexadécimale pour l'affichage
icon = db.Column(db.String(32), nullable=True) # Nom d'icône (ex: 'user', 'shield', 'crown')
class PagePermission(db.Model):
__tablename__ = "webapp_page_permission"
id = db.Column(db.Integer, primary_key=True)
page_key = db.Column(db.String(64), unique=True, nullable=False)
min_level = db.Column(db.Integer, nullable=False, default=0)
write_level = db.Column(db.Integer, nullable=True)
category = db.Column(db.String(32), nullable=True, default="general") # Catégorie: 'general', 'moderation', 'content', 'config'
description = db.Column(db.String(256), nullable=True) # Description de la page
class WebappUser(db.Model, UserMixin):
__tablename__ = "webapp_user"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False)
email = db.Column(db.String(256), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
role = db.Column(db.String(64), nullable=False, default="viewer_twitch")
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
def get_level(self) -> int:
return role_level(self.role)
def has_role_at_least(self, min_role: str) -> bool:
return self.get_level() >= role_level(min_role)
def has_level_at_least(self, level: int) -> bool:
return self.get_level() >= level
def has_any_role(self, roles: list) -> bool:
return self.role in roles
class Configuration(db.Model):
key = db.Column(db.String(32), primary_key=True)
@@ -25,20 +83,36 @@ class LiveAlert(db.Model):
online = db.Column(db.Boolean, default=False)
login = db.Column(db.String(128))
notify_channel = db.Column(db.Integer)
message = db.Column(db.String(2000))
message = db.Column(db.String(2000)) # message optionnel avant l'embed
watch_activity = db.Column(db.Boolean, default=False)
# Personnalisation de l'embed Discord (comme YouTube)
embed_title = db.Column(db.String(256))
embed_description = db.Column(db.String(2000))
embed_color = db.Column(db.String(8), default='9146FF') # violet Twitch
embed_footer = db.Column(db.String(2048))
embed_author_name = db.Column(db.String(256))
embed_author_icon = db.Column(db.String(512))
embed_thumbnail = db.Column(db.Boolean, default=True)
embed_image = db.Column(db.Boolean, default=True)
class Message(db.Model):
class TwitchAnnouncement(db.Model):
__tablename__ = 'twitch_announcement'
id = db.Column(db.Integer, primary_key=True)
enable = db.Column(db.Boolean, default=False)
text = db.Column(db.String(256))
periodicity = db.Column(db.Integer)
enable = db.Column(db.Boolean, default=True)
name = db.Column(db.String(64))
text = db.Column(db.String(500))
periodicity = db.Column(db.Integer, default=10)
min_chat_messages = db.Column(db.Integer, default=0)
last_sent = db.Column(db.DateTime, nullable=True)
# Niveaux de permission Twitch pour les commandes personnalisées : viewer, sub, vip, moderator
class Commande(db.Model):
id = db.Column(db.Integer, primary_key=True)
discord_enable = db.Column(db.Boolean, default=True)
twitch_enable = db.Column(db.Boolean, default=True)
trigger = db.Column(db.String(32), unique=True)
response = db.Column(db.String(2000))
twitch_permission = db.Column(db.String(16), default='viewer') # viewer | sub | vip | moderator
class ModerationEvent(db.Model):
id = db.Column(db.Integer, primary_key=True)
@@ -51,6 +125,46 @@ class ModerationEvent(db.Model):
staff_name = db.Column(db.String(256))
duration = db.Column(db.Integer)
class TwitchModerationLog(db.Model):
__tablename__ = 'twitch_moderation_log'
id = db.Column(db.Integer, primary_key=True)
action = db.Column(db.String(32))
moderator = db.Column(db.String(256))
target = db.Column(db.String(256))
details = db.Column(db.String(512))
created_at = db.Column(db.DateTime)
class TwitchLinkFilter(db.Model):
__tablename__ = 'twitch_link_filter'
id = db.Column(db.Integer, primary_key=True)
enabled = db.Column(db.Boolean, default=False)
allow_subscribers = db.Column(db.Boolean, default=True)
allow_vips = db.Column(db.Boolean, default=True)
allow_moderators = db.Column(db.Boolean, default=True)
timeout_duration = db.Column(db.Integer, default=60)
warning_message = db.Column(db.String(500), default="Les liens ne sont pas autorises dans le chat.")
class TwitchAllowedDomain(db.Model):
__tablename__ = 'twitch_allowed_domain'
id = db.Column(db.Integer, primary_key=True)
domain = db.Column(db.String(256), unique=True)
class TwitchPermit(db.Model):
__tablename__ = 'twitch_permit'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(256))
expires_at = db.Column(db.DateTime)
class TwitchAllowedUser(db.Model):
__tablename__ = 'twitch_allowed_user'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(256), unique=True)
class AntiCheatCache(db.Model):
__tablename__ = 'anticheat_cache'
steam_id = db.Column(db.String(32), primary_key=True)
@@ -61,3 +175,55 @@ class AntiCheatCache(db.Model):
notes = db.Column(db.String(1024))
updated_at = db.Column(db.DateTime)
class YouTubeNotification(db.Model):
__tablename__ = 'youtube_notification'
id = db.Column(db.Integer, primary_key=True)
enable = db.Column(db.Boolean, default=True)
channel_id = db.Column(db.String(128))
notify_channel = db.Column(db.Integer)
message = db.Column(db.String(2000))
video_type = db.Column(db.String(16), default='all')
last_video_id = db.Column(db.String(128))
embed_title = db.Column(db.String(256))
embed_description = db.Column(db.String(2000))
embed_color = db.Column(db.String(8), default='FF0000')
embed_footer = db.Column(db.String(2048))
embed_author_name = db.Column(db.String(256))
embed_author_icon = db.Column(db.String(512))
embed_thumbnail = db.Column(db.Boolean, default=True)
embed_image = db.Column(db.Boolean, default=True)
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)
+128 -6
View File
@@ -28,14 +28,26 @@ CREATE TABLE IF NOT EXISTS live_alert (
`online` BOOLEAN NOT NULL DEFAULT FALSE,
`login` VARCHAR(128) UNIQUE NOT NULL,
`notify_channel` INTEGER NOT NULL,
`message` VARCHAR(2000) NOT NULL
`message` VARCHAR(2000),
`watch_activity` BOOLEAN NOT NULL DEFAULT FALSE,
`embed_title` VARCHAR(256),
`embed_description` VARCHAR(2000),
`embed_color` VARCHAR(8) DEFAULT '9146FF',
`embed_footer` VARCHAR(2048),
`embed_author_name` VARCHAR(256),
`embed_author_icon` VARCHAR(512),
`embed_thumbnail` BOOLEAN DEFAULT TRUE,
`embed_image` BOOLEAN DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS `message` (
CREATE TABLE IF NOT EXISTS `twitch_announcement` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`enable` BOOLEAN NOT NULL DEFAULT FALSE,
`text` VARCHAR(256) NULL,
periodicity INTEGER NULL
`enable` BOOLEAN NOT NULL DEFAULT TRUE,
`name` VARCHAR(64) NOT NULL,
`text` VARCHAR(500) NOT NULL,
`periodicity` INTEGER NOT NULL DEFAULT 10,
`min_chat_messages` INTEGER NOT NULL DEFAULT 0,
`last_sent` DATETIME NULL
);
CREATE TABLE IF NOT EXISTS `commande` (
@@ -43,7 +55,8 @@ CREATE TABLE IF NOT EXISTS `commande` (
`discord_enable` BOOLEAN NOT NULL DEFAULT TRUE,
`twitch_enable` BOOLEAN NOT NULL DEFAULT TRUE,
`trigger` VARCHAR(16) UNIQUE NOT NULL,
`response` VARCHAR(2000) NOT NULL
`response` VARCHAR(2000) NOT NULL,
`twitch_permission` VARCHAR(16) DEFAULT 'viewer'
);
CREATE TABLE IF NOT EXISTS `moderation_event` (
@@ -58,6 +71,15 @@ CREATE TABLE IF NOT EXISTS `moderation_event` (
`duration` INTEGER NULL
);
CREATE TABLE IF NOT EXISTS `twitch_moderation_log` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`action` VARCHAR(32) NOT NULL,
`moderator` VARCHAR(256) NOT NULL,
`target` VARCHAR(256),
`details` VARCHAR(512),
`created_at` DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS `anticheat_cache` (
steam_id VARCHAR(32) PRIMARY KEY,
game_name VARCHAR(256) NOT NULL,
@@ -76,3 +98,103 @@ CREATE TABLE IF NOT EXISTS `member_invites` (
`inviter_name` VARCHAR(256),
`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,
`channel_id` VARCHAR(128) NOT NULL,
`notify_channel` INTEGER NOT NULL,
`message` VARCHAR(2000) NOT NULL,
`video_type` VARCHAR(16) NOT NULL DEFAULT 'all',
`last_video_id` VARCHAR(128),
`embed_title` VARCHAR(256),
`embed_description` VARCHAR(2000),
`embed_color` VARCHAR(8) NOT NULL DEFAULT 'FF0000',
`embed_footer` VARCHAR(2048),
`embed_author_name` VARCHAR(256),
`embed_author_icon` VARCHAR(512),
`embed_thumbnail` BOOLEAN NOT NULL DEFAULT TRUE,
`embed_image` BOOLEAN NOT NULL DEFAULT TRUE
);
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
);
+38 -2
View File
@@ -3,11 +3,13 @@ import discord
import logging
import random
from webapp import webapp
from database import db
from database.helpers import ConfigurationHelper
from database.models import Configuration, Humeur, Commande
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,
@@ -22,11 +24,15 @@ from discordbot.moderation import (
handle_say_command
)
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):
async def on_ready(self):
logging.info(f'Connecté en tant que {self.user} (ID: {self.user.id})')
webapp.config["BOT_STATUS"]["discord_connected"] = True
webapp.config["BOT_STATUS"]["discord_guild_count"] = len(self.guilds)
for c in self.get_all_channels() :
logging.info(f'{c.id} {c.name}')
@@ -35,6 +41,11 @@ 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
async def updateStatus(self):
while not self.is_closed():
@@ -51,13 +62,30 @@ class DiscordBot(discord.Client):
await checkHumbleBundleAndNotify(self)
await asyncio.sleep(30*60)
async def updateYouTube(self):
while not self.is_closed():
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():
if isinstance(channel, TextChannel):
channels.append(channel)
return channels
def getAllVoiceChannels(self) -> list[VoiceChannel]:
channels = []
for channel in self.get_all_channels():
if isinstance(channel, VoiceChannel):
channels.append(channel)
return channels
def getAllRoles(self):
guilds_roles = []
for guild in self.guilds:
@@ -248,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)
+332
View File
@@ -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 lembed de config avec infos du salon."""
embed = discord.Embed(
title="Configuration du salon",
description=(
"Voici lespace 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 nexiste 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 lajouter/retirer.")
elif action == "blacklist":
await channel.send("Liste noire : mentionnez un membre pour lajouter/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 lid 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 denvoyer 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
+232
View File
@@ -0,0 +1,232 @@
# FreeLoot Discord : notifications depuis le feed LootScraper (jeux gratuits Epic, Amazon Prime, GOG, etc.)
import asyncio
import logging
from discord import Client
from database import db
from database.helpers import ConfigurationHelper
from database.models import FreeLootEntry
from freeloot_feed import (
SOURCES,
fetch_feed,
source_key_from_entry,
game_name_from_title,
extract_image_from_content,
extract_description_from_content,
extract_valid_to_from_content,
extract_recommended_price_from_content,
extract_genres_from_content,
extract_rating_from_content,
)
def _get_mention_content() -> str:
"""Construit le contenu du message (mentions) depuis la config."""
raw = ConfigurationHelper().getValue("freeloot_mention")
if not raw or not str(raw).strip():
return ""
parts = []
for s in str(raw).strip().split(","):
s = s.strip()
if s == "everyone":
parts.append("@everyone")
elif s == "here":
parts.append("@here")
elif s.isdigit():
parts.append(f"<@&{s}>")
return " ".join(parts) if parts else ""
def _is_enabled_source(source_key: str) -> bool:
"""Vérifie si cette source est activée dans la config (freeloot_sources)."""
raw = ConfigurationHelper().getValue("freeloot_sources")
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
return True
enabled = [s.strip() for s in str(raw).split(",") if s.strip()]
return source_key in enabled if enabled else True
def _store_label_for_title(source_key: str) -> str:
"""Libellé court pour le titre style DraftBot (ex: 'l'Epic Games Store')."""
labels = {
"epic_pc": "l'Epic Games Store",
"epic_android": "l'Epic Games Store (Android)",
"epic_ios": "l'Epic Games Store (iOS)",
"amazon_prime": "Amazon Prime Gaming",
"gog": "GOG",
"google_play": "Google Play",
"apple_app_store": "l'App Store",
}
return labels.get(source_key, "la boutique")
# Logo (thumbnail) de chaque boutique pour l'embed Discord (affiché en haut à droite)
SOURCE_LOGO_URLS = {
"epic_pc": "https://store.epicgames.com/favicon.ico",
"epic_android": "https://store.epicgames.com/favicon.ico",
"epic_ios": "https://store.epicgames.com/favicon.ico",
"amazon_prime": "https://gaming.amazon.com/favicon.ico",
"gog": "https://www.gog.com/favicon.ico",
"google_play": "https://play.google.com/favicon.ico",
"apple_app_store": "https://www.apple.com/favicon.ico",
}
def _build_embed(entry: dict, source_key: str):
import discord
game_name = game_name_from_title(entry["title"])
source_label = next((s[1] for s in SOURCES if s[0] == source_key), source_key)
link = entry.get("link") or ""
content_raw = entry.get("content") or ""
img_url = extract_image_from_content(content_raw)
description = extract_description_from_content(content_raw, max_len=350)
valid_to = extract_valid_to_from_content(content_raw)
store_title = _store_label_for_title(source_key)
# Couleur barre gauche style DraftBot (orange-rouge)
color = 0xE67E22
title = f"{game_name} gratuit sur {store_title} !"
embed = discord.Embed(
title=title,
url=link if link.startswith("http") else None,
color=color,
)
if description:
embed.description = description
# Prix / gratuit / validité (Discord : pas de couleur dans le texte, seulement **gras** / markdown)
value_parts = ["**Gratuit**"]
if valid_to:
try:
from datetime import datetime
end = datetime.fromisoformat(valid_to.replace("Z", "+00:00"))
value_parts.append(f"jusqu'au {end.strftime('%d/%m/%Y')}")
except Exception:
value_parts.append(f"jusqu'au {valid_to[:10]}")
embed.add_field(
name="Prix",
value="".join(value_parts),
inline=False,
)
recommended_price = extract_recommended_price_from_content(content_raw)
if recommended_price:
embed.add_field(name="Prix recommandé", value=recommended_price, inline=True)
genres = extract_genres_from_content(content_raw)
if genres:
embed.add_field(name="Genres", value=genres, inline=True)
rating = extract_rating_from_content(content_raw)
if rating:
embed.add_field(name="Ratings", value=rating, inline=True)
if link and link.startswith("http"):
embed.add_field(
name="\u200b",
value=f"[Ouvrir dans la boutique !]({link})",
inline=False,
)
# Thumbnail (logo de la boutique en haut à droite)
logo_url = SOURCE_LOGO_URLS.get(source_key)
if logo_url and logo_url.startswith("http"):
embed.set_thumbnail(url=logo_url)
# Image principale (style DraftBot)
if img_url and img_url.startswith("http"):
embed.set_image(url=img_url)
embed.set_footer(text="MamieHenriette • FreeLoot")
return embed
_freeloot_first_check = True
async def checkFreeLootAndNotify(bot: Client):
global _freeloot_first_check
helper = ConfigurationHelper()
if not helper.getValue("freeloot_enable"):
return
channel_id = helper.getIntValue("freeloot_channel_id")
if not channel_id:
return
channel = bot.get_channel(channel_id)
if not channel:
logging.warning("FreeLoot: canal Discord introuvable")
return
entries = fetch_feed()
if not entries:
return
# Au premier check après le démarrage, on synchronise sans notifier
if _freeloot_first_check:
logging.info("FreeLoot: première vérification, synchronisation sans notification")
for entry in entries:
entry_id = entry["id"]
if not FreeLootEntry.query.get(entry_id):
source_key = source_key_from_entry(entry["title"], entry["link"])
if source_key and _is_enabled_source(source_key):
try:
db.session.add(FreeLootEntry(entry_id=entry_id))
db.session.commit()
except Exception as e:
logging.error(f"FreeLoot: erreur de synchronisation pour {entry_id}: {e}")
db.session.rollback()
_freeloot_first_check = False
return
# Vérifications suivantes : notification normale
for entry in entries:
entry_id = entry["id"]
if FreeLootEntry.query.get(entry_id):
continue
source_key = source_key_from_entry(entry["title"], entry["link"])
if not source_key or not _is_enabled_source(source_key):
continue
try:
embed = _build_embed(entry, source_key)
content = _get_mention_content()
await channel.send(content=content or None, embed=embed)
db.session.add(FreeLootEntry(entry_id=entry_id))
db.session.commit()
except Exception as e:
logging.error(f"FreeLoot: envoi Discord échoué pour {entry_id}: {e}")
db.session.rollback()
async def _send_entry_to_discord_async(bot: Client, entry_id: str) -> tuple[bool, str]:
"""
Envoie une entrée FreeLoot sur Discord (appel manuel). Retourne (succès, message).
"""
channel_id = ConfigurationHelper().getIntValue("freeloot_channel_id")
if not channel_id:
return (False, "Aucun canal Discord configuré pour FreeLoot.")
channel = bot.get_channel(channel_id)
if not channel:
return (False, "Canal Discord introuvable.")
entries = fetch_feed()
if not entries:
return (False, "Impossible de charger le flux.")
entry = next((e for e in entries if e.get("id") == entry_id), None)
if not entry:
return (False, "Entrée introuvable dans le flux.")
source_key = source_key_from_entry(entry["title"], entry["link"])
if not source_key:
return (False, "Source non reconnue pour cette entrée.")
try:
embed = _build_embed(entry, source_key)
content = _get_mention_content()
await channel.send(content=content or None, embed=embed)
if not FreeLootEntry.query.get(entry_id):
db.session.add(FreeLootEntry(entry_id=entry_id))
db.session.commit()
return (True, "Annonce envoyée sur Discord.")
except Exception as e:
logging.error(f"FreeLoot: envoi manuel échoué pour {entry_id}: {e}")
db.session.rollback()
return (False, str(e))
def send_entry_to_discord_sync(bot: Client, entry_id: str) -> tuple[bool, str]:
"""Appel synchrone pour envoyer une entrée sur Discord (depuis la webapp)."""
try:
future = asyncio.run_coroutine_threadsafe(
_send_entry_to_discord_async(bot, entry_id),
bot.loop,
)
return future.result(timeout=15)
except Exception as e:
logging.error(f"FreeLoot: send_entry_to_discord_sync: {e}")
return (False, str(e))
+14
View File
@@ -8,6 +8,8 @@ from database.helpers import ConfigurationHelper
from database.models import GameBundle
from discord import Client
_humblebundle_first_check = True
def _isEnable():
helper = ConfigurationHelper()
@@ -40,10 +42,22 @@ def _formatMessage(bundle):
return message
async def checkHumbleBundleAndNotify(bot: Client):
global _humblebundle_first_check
if _isEnable() :
try :
bundles = _callGithub()
bundle = _findFirstNotNotified(bundles)
# Premier check : synchronisation sans notification
if _humblebundle_first_check:
if bundle != None:
logging.info(f'HumbleBundle: première vérification, synchronisation sans notification pour {bundle["name"]}')
db.session.add(GameBundle(url=bundle['url'], name=bundle['name'], json = json.dumps(bundle)))
db.session.commit()
_humblebundle_first_check = False
return
# Vérifications normales ensuite
if bundle != None :
message = _formatMessage(bundle)
await bot.get_channel(ConfigurationHelper().getIntValue('humble_bundle_channel')).send(message)
+242
View File
@@ -0,0 +1,242 @@
import logging
import asyncio
import xml.etree.ElementTree as ET
import requests
from database import db
from database.models import YouTubeNotification
from webapp import webapp
logger = logging.getLogger('youtube-notification')
logger.setLevel(logging.INFO)
_youtube_first_check = True
async def checkYouTubeVideos():
global _youtube_first_check
with webapp.app_context():
try:
notifications: list[YouTubeNotification] = YouTubeNotification.query.filter_by(enable=True).all()
for notification in notifications:
try:
await _checkChannelVideos(notification, is_first_check=_youtube_first_check)
except Exception as e:
logger.error(f"Erreur lors de la vérification de la chaîne {notification.channel_id}: {e}")
continue
# Après la première vérification complète, on désactive le flag
if _youtube_first_check:
_youtube_first_check = False
logger.info("YouTube: première vérification terminée, notifications activées")
except Exception as e:
logger.error(f"Erreur lors de la vérification YouTube: {e}")
async def _checkChannelVideos(notification: YouTubeNotification, is_first_check: bool = False):
try:
channel_id = notification.channel_id
rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
response = await asyncio.to_thread(requests.get, rss_url, timeout=10)
if response.status_code != 200:
logger.error(f"Erreur HTTP {response.status_code} lors de la récupération du RSS pour {channel_id}")
return
root = ET.fromstring(response.content)
ns = {'atom': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015', 'media': 'http://search.yahoo.com/mrss/'}
entries = root.findall('atom:entry', ns)
if not entries:
logger.warning(f"Aucune vidéo trouvée dans le RSS pour {channel_id}")
return
videos = []
for entry in entries:
video_id = entry.find('yt:videoId', ns)
if video_id is None:
continue
video_id = video_id.text
title_elem = entry.find('atom:title', ns)
video_title = title_elem.text if title_elem is not None else 'Sans titre'
link_elem = entry.find('atom:link', ns)
video_url = link_elem.get('href') if link_elem is not None else f"https://www.youtube.com/watch?v={video_id}"
published_elem = entry.find('atom:published', ns)
published_at = published_elem.text if published_elem is not None else ''
author_elem = entry.find('atom:author/atom:name', ns)
channel_name = author_elem.text if author_elem is not None else 'Inconnu'
thumbnail = None
media_thumbnail = entry.find('media:group/media:thumbnail', ns)
if media_thumbnail is not None:
thumbnail = media_thumbnail.get('url')
is_short = False
if video_title and ('#shorts' in video_title.lower() or '#short' in video_title.lower()):
is_short = True
if notification.video_type == 'all':
videos.append((video_id, {
'title': video_title,
'url': video_url,
'published': published_at,
'channel_name': channel_name,
'thumbnail': thumbnail,
'is_short': is_short
}))
elif notification.video_type == 'short' and is_short:
videos.append((video_id, {
'title': video_title,
'url': video_url,
'published': published_at,
'channel_name': channel_name,
'thumbnail': thumbnail,
'is_short': is_short
}))
elif notification.video_type == 'video' and not is_short:
videos.append((video_id, {
'title': video_title,
'url': video_url,
'published': published_at,
'channel_name': channel_name,
'thumbnail': thumbnail,
'is_short': is_short
}))
videos.sort(key=lambda x: x[1]['published'], reverse=True)
if videos:
latest_video_id, latest_video = videos[0]
# Si c'est la première vérification après démarrage, on synchronise sans notifier
if is_first_check:
if not notification.last_video_id or notification.last_video_id != latest_video_id:
logger.info(f"YouTube: synchronisation initiale pour {channel_id}, dernière vidéo: {latest_video_id}")
notification.last_video_id = latest_video_id
db.session.commit()
return
# Vérifications normales ensuite
if not notification.last_video_id:
notification.last_video_id = latest_video_id
db.session.commit()
return
if latest_video_id != notification.last_video_id:
logger.info(f"Nouvelle vidéo détectée: {latest_video_id} pour la chaîne {notification.channel_id}")
await _notifyVideo(notification, latest_video, latest_video_id)
notification.last_video_id = latest_video_id
db.session.commit()
except Exception as e:
logger.error(f"Erreur lors de la vérification des vidéos: {e}")
async def _notifyVideo(notification: YouTubeNotification, video_data: dict, video_id: str):
from discordbot import bot
try:
channel_name = video_data.get('channel_name', 'Inconnu')
video_title = video_data.get('title', 'Sans titre')
video_url = video_data.get('url', f"https://www.youtube.com/watch?v={video_id}")
thumbnail = video_data.get('thumbnail', '')
published_at = video_data.get('published', '')
is_short = video_data.get('is_short', False)
try:
message = notification.message.format(
channel_name=channel_name or 'Inconnu',
video_title=video_title or 'Sans titre',
video_url=video_url,
video_id=video_id,
thumbnail=thumbnail or '',
published_at=published_at or '',
is_short=is_short
)
except KeyError as e:
logger.error(f"Variable manquante dans le message de notification: {e}")
message = f"🎥 Nouvelle vidéo de {channel_name}: [{video_title}]({video_url})"
logger.info(f"Envoi de notification YouTube: {message}")
bot.loop.create_task(_sendMessage(notification, message, video_url, thumbnail, video_title, channel_name, video_id, published_at, is_short))
except Exception as e:
logger.error(f"Erreur lors de la notification: {e}")
def _format_embed_text(text: str, channel_name: str, video_title: str, video_url: str, video_id: str, thumbnail: str, published_at: str, is_short: bool) -> str:
"""Formate un texte d'embed avec les variables disponibles"""
if not text:
return None
try:
return text.format(
channel_name=channel_name or 'Inconnu',
video_title=video_title or 'Sans titre',
video_url=video_url,
video_id=video_id,
thumbnail=thumbnail or '',
published_at=published_at or '',
is_short=is_short
)
except KeyError:
return text
async def _sendMessage(notification: YouTubeNotification, message: str, video_url: str, thumbnail: str, video_title: str, channel_name: str, video_id: str, published_at: str, is_short: bool):
from discordbot import bot
try:
discord_channel = bot.get_channel(notification.notify_channel)
if not discord_channel:
logger.error(f"Canal Discord {notification.notify_channel} introuvable")
return
import discord
embed_title = _format_embed_text(notification.embed_title, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if notification.embed_title else video_title
embed_description = _format_embed_text(notification.embed_description, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if notification.embed_description else None
try:
embed_color = int(notification.embed_color or 'FF0000', 16)
except ValueError:
embed_color = 0xFF0000
embed = discord.Embed(
title=embed_title,
url=video_url,
color=embed_color
)
if embed_description:
embed.description = embed_description
author_name = _format_embed_text(notification.embed_author_name, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if notification.embed_author_name else channel_name
author_icon = notification.embed_author_icon if notification.embed_author_icon else "https://www.youtube.com/img/desktop/yt_1200.png"
embed.set_author(name=author_name, icon_url=author_icon)
if notification.embed_thumbnail and thumbnail:
embed.set_thumbnail(url=thumbnail)
if notification.embed_image and thumbnail:
embed.set_image(url=thumbnail)
if notification.embed_footer:
footer_text = _format_embed_text(notification.embed_footer, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short)
if footer_text:
embed.set_footer(text=footer_text)
if message and message.strip():
await discord_channel.send(message, embed=embed)
else:
await discord_channel.send(embed=embed)
logger.info(f"Notification YouTube envoyée avec succès")
except Exception as e:
logger.error(f"Erreur lors de l'envoi du message Discord: {e}")
+200
View File
@@ -0,0 +1,200 @@
# Module partagé : récupération et parsing du flux LootScraper (sans dépendance Discord)
import re
import xml.etree.ElementTree as ET
from html import unescape
import requests
FEED_URL = "https://feed.eikowagenknecht.com/lootscraper.xml"
ATOM_NS = {"atom": "http://www.w3.org/2005/Atom"}
SOURCES = [
("epic_pc", "Epic Games (PC)", "🖥️"),
("epic_android", "Epic Games (Android)", "🤖"),
("epic_ios", "Epic Games (iOS)", "🍎"),
("amazon_prime", "Amazon Prime Gaming", "📦"),
("gog", "GOG", "🎮"),
("google_play", "Google Play", "🤖"),
("apple_app_store", "Apple App Store", "🍎"),
]
def source_key_from_entry(title: str, link: str) -> str | None:
"""Détermine la clé source+plateforme depuis le titre et le lien."""
title_upper = (title or "").upper()
link_lower = (link or "").lower()
if "EPIC GAMES" in title_upper:
if "-ios-" in link_lower or "/ios-" in link_lower:
return "epic_ios"
if "-android-" in link_lower or "/android-" in link_lower:
return "epic_android"
return "epic_pc"
if "AMAZON PRIME" in title_upper:
return "amazon_prime"
if "GOG" in title_upper:
return "gog"
if "GOOGLE PLAY" in title_upper:
return "google_play"
if "APPLE APP STORE" in title_upper:
return "apple_app_store"
return None
def game_name_from_title(title: str) -> str:
"""Extrait le nom du jeu depuis le titre."""
if not title:
return "Jeu gratuit"
for prefix in (
"Epic Games (Game) - ",
"Amazon Prime (Game) - ",
"GOG (Game) - ",
"GOG (Game, Always Free) - ",
"Google Play (Game) - ",
"Apple App Store (Game) - ",
):
if title.startswith(prefix):
return unescape(title[len(prefix) :].strip())
if " - " in title:
return unescape(title.split(" - ", 1)[1].strip())
return unescape(title.strip())
def extract_image_from_content(content: str) -> str | None:
"""Extrait l'URL de la première image du contenu HTML (toutes balises img)."""
if not content:
return None
def _normalize_url(url: str) -> str:
u = unescape(url.strip()).replace("&amp;", "&")
return u
# 1) Balises <img ... src="url" ...>
for m in re.finditer(r'<img[^>]+src\s*=\s*["\']([^"\']+)["\']', content, re.I):
url = _normalize_url(m.group(1))
if url.startswith("http"):
return url
# 2) Fallback: toute attribution src="http..." (certains CDN n'ont pas d'extension)
for m in re.finditer(r'src\s*=\s*["\'](https?://[^"\']{20,})["\']', content, re.I):
url = _normalize_url(m.group(1))
if url.startswith("http"):
return url
return None
def extract_description_from_content(content: str, max_len: int = 400) -> str | None:
"""Extrait la description du jeu depuis le contenu HTML (balise <b>Description:</b>)."""
if not content:
return None
m = re.search(r"<b>Description:</b>\s*([^<]+)", content, re.I | re.DOTALL)
if not m:
return None
desc = unescape(m.group(1).strip())
desc = re.sub(r"\s+", " ", desc)
if len(desc) > max_len:
desc = desc[: max_len - 3].rsplit(" ", 1)[0] + "..."
return desc or None
def extract_valid_to_from_content(content: str) -> str | None:
"""Extrait la date 'Offer valid to' depuis le contenu HTML."""
if not content:
return None
m = re.search(r"<b>Offer valid to:</b>\s*(\d{4}-\d{2}-\d{2}[^<]*)", content, re.I)
return m.group(1).strip() if m else None
def extract_recommended_price_from_content(content: str) -> str | None:
"""Extrait le prix recommandé (ex: '39.99 EUR') depuis le contenu HTML."""
if not content:
return None
m = re.search(r"<b>Recommended price\s*\([^)]*\):\s*</b>\s*([^<]+)", content, re.I)
if not m:
m = re.search(r"Recommended price[^<]*</b>\s*([^<]+)", content, re.I)
return unescape(m.group(1).strip()) if m else None
def extract_genres_from_content(content: str) -> str | None:
"""Extrait les genres (ex: 'Action, Indie') depuis le contenu HTML."""
if not content:
return None
m = re.search(r"<b>Genres:</b>\s*([^<]+)", content, re.I)
return unescape(m.group(1).strip()) if m else None
def extract_rating_from_content(content: str) -> str | None:
"""Extrait le rating (texte après Ratings:, liens ou texte brut)."""
if not content:
return None
m = re.search(r"<b>Ratings:</b>\s*(.+?)</li>", content, re.I | re.DOTALL)
if not m:
return None
raw = m.group(1)
raw = re.sub(r"<a[^>]*>([^<]*)</a>", r"\1", raw)
raw = re.sub(r"<[^>]+>", " ", raw)
raw = unescape(re.sub(r"\s+", " ", raw).strip())
return raw if len(raw) > 0 and len(raw) < 200 else None
def fetch_feed() -> list[dict] | None:
"""Récupère et parse le flux Atom, retourne une liste d'entrées brutes."""
try:
r = requests.get(FEED_URL, timeout=15)
r.raise_for_status()
root = ET.fromstring(r.content)
entries = []
for entry_el in root.findall(".//atom:entry", ATOM_NS):
entry_id_el = entry_el.find("atom:id", ATOM_NS)
title_el = entry_el.find("atom:title", ATOM_NS)
link_el = entry_el.find("atom:link", ATOM_NS)
content_el = entry_el.find("atom:content", ATOM_NS)
updated_el = entry_el.find("atom:updated", ATOM_NS) or entry_el.find("atom:published", ATOM_NS)
entry_id = entry_id_el.text.strip() if entry_id_el is not None and entry_id_el.text else None
title = title_el.text.strip() if title_el is not None and title_el.text else None
link = link_el.get("href") if link_el is not None else None
content = ""
if content_el is not None:
# Sérialiser tout le sous-arbre pour ne rien perdre (notamment les <img>)
content = ET.tostring(content_el, encoding="unicode", method="xml")
# Normaliser les préfixes de namespace (ex: <html:b> -> <b>) pour que les regex d'extraction matchent
content = content.replace("</html:", "</").replace("<html:", "<")
updated = updated_el.text.strip() if updated_el is not None and updated_el.text else None
if entry_id and title:
entries.append({
"id": entry_id,
"title": title,
"link": link or "",
"content": content or "",
"updated": updated,
})
return entries
except Exception:
return None
def get_display_entries() -> list[dict]:
"""Retourne la liste des entrées formatées pour affichage (webapp)."""
raw = fetch_feed()
if not raw:
return []
result = []
for e in raw:
sk = source_key_from_entry(e["title"], e["link"])
content = e.get("content") or ""
desc = extract_description_from_content(content)
result.append({
"id": e["id"],
"title": e["title"],
"link": e["link"],
"game_name": game_name_from_title(e["title"]),
"source_key": sk or "other",
"source_label": next((s[1] for s in SOURCES if s[0] == sk), sk or "Autre"),
"emoji": next((s[2] for s in SOURCES if s[0] == sk), "🎁"),
"image_url": extract_image_from_content(content),
"description": desc,
"valid_to": extract_valid_to_from_content(content),
"recommended_price": extract_recommended_price_from_content(content),
"genres": extract_genres_from_content(content),
"rating": extract_rating_from_content(content),
"updated": e.get("updated"),
})
return result
+1
View File
@@ -11,6 +11,7 @@ twitchAPI>=4.5.0
# Nécessaire pour l'hébergement du site web
flask>=2.3.2
flask-login>=0.6.3
flask-sqlalchemy>=3.0.3
flask[async]
waitress>=3.0.2
+214 -25
View File
@@ -1,4 +1,3 @@
import asyncio
import logging
@@ -7,66 +6,256 @@ 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')
with webapp.app_context():
await ready_event.chat.join_room(ConfigurationHelper().getValue('twitch_channel'))
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)
# commande qui répond "bonjour" à "!hello"
async def _helloCommand(msg: ChatMessage):
await msg.reply(f'Bonjour {msg.user.name}')
def _isConfigured() -> bool:
helper = ConfigurationHelper()
return helper.getValue('twitch_client_id') != None and helper.getValue('twitch_client_secret') != None and helper.getValue('twitch_access_token') != None and helper.getValue('twitch_refresh_token') != None and helper.getValue('twitch_channel') != None
class TwitchBot() :
def _isConfigured():
helper = ConfigurationHelper()
return (helper.getValue('twitch_client_id') is not None and
helper.getValue('twitch_client_secret') is not None and
helper.getValue('twitch_access_token') is not None and
helper.getValue('twitch_refresh_token') is not None and
helper.getValue('twitch_channel') is not None)
class TwitchBot():
_eventsub = None
async def _connect(self):
with webapp.app_context():
if _isConfigured() :
try :
if _isConfigured():
try:
helper = ConfigurationHelper()
self.twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
await self.twitch.set_user_authentication(helper.getValue('twitch_access_token'), USER_SCOPE, helper.getValue('twitch_refresh_token'))
self.chat = await Chat(self.twitch)
self.chat.register_event(ChatEvent.READY, _onReady)
self.chat.register_event(ChatEvent.MESSAGE, _onMessage)
# chat.register_event(ChatEvent.SUB, on_sub)
self.chat.register_command('hello', _helloCommand)
self._register_moderation_commands()
self.chat.start()
except Exception as e:
logging.error(f'Échec de l\'authentification Twitch. Vérifiez vos identifiants et redémarrez après correction : {e}')
else:
except Exception as e:
logging.error(f'Échec de l\'authentification Twitch : {e}')
else:
logging.info("Twitch n'est pas configuré")
async def _checkOnlineStreamers(self):
# pas bon faudrait faire un truc mieux
while True :
def _register_moderation_commands(self):
# Créer des wrappers async pour chaque commande
async def cmd_timeout(msg): await moderation.timeout_command(msg, self.twitch)
async def cmd_ban(msg): await moderation.ban_command(msg, self.twitch)
async def cmd_unban(msg): await moderation.unban_command(msg, self.twitch)
async def cmd_clean(msg): await moderation.clean_command(msg, self.twitch)
async def cmd_shieldmode(msg): await moderation.shieldmode_command(msg, self.twitch)
async def cmd_settitle(msg): await moderation.settitle_command(msg, self.twitch)
async def cmd_setgame(msg): await moderation.setgame_command(msg, self.twitch)
async def cmd_subon(msg): await moderation.subon_command(msg, self.twitch)
async def cmd_suboff(msg): await moderation.suboff_command(msg, self.twitch)
async def cmd_follon(msg): await moderation.follon_command(msg, self.twitch)
async def cmd_folloff(msg): await moderation.folloff_command(msg, self.twitch)
async def cmd_emoteon(msg): await moderation.emoteon_command(msg, self.twitch)
async def cmd_emoteoff(msg): await moderation.emoteoff_command(msg, self.twitch)
async def cmd_ann(msg): await moderation.ann_command(msg, self.twitch)
async def cmd_no_game(msg): await moderation.no_game_command(msg, self.twitch)
async def cmd_multitwitch(msg): await moderation.multitwitch_command(msg, self.twitch)
async def cmd_permit(msg): await link_filter.permit_command(msg, self.twitch)
self.chat.register_command('kick', cmd_timeout)
self.chat.register_command('to', cmd_timeout)
self.chat.register_command('timeout', cmd_timeout)
self.chat.register_command('tm', cmd_timeout)
self.chat.register_command('ban', cmd_ban)
self.chat.register_command('unban', cmd_unban)
self.chat.register_command('clean', cmd_clean)
self.chat.register_command('shieldmode', cmd_shieldmode)
self.chat.register_command('settitle', cmd_settitle)
self.chat.register_command('setgame', cmd_setgame)
self.chat.register_command('setcateg', cmd_setgame)
self.chat.register_command('subon', cmd_subon)
self.chat.register_command('suboff', cmd_suboff)
self.chat.register_command('follon', cmd_follon)
self.chat.register_command('folloff', cmd_folloff)
self.chat.register_command('emoteon', cmd_emoteon)
self.chat.register_command('emoteoff', cmd_emoteoff)
self.chat.register_command('ann', cmd_ann)
self.chat.register_command('no_game', cmd_no_game)
self.chat.register_command('multitwitch', cmd_multitwitch)
self.chat.register_command('permit', cmd_permit)
async def _checkOnlineStreamers(self):
while True:
try:
await checkOnlineStreamer(self.twitch)
except Exception as e:
logging.error(f'Erreur lors lors du check des streamers online : {e}')
# toutes les 5 minutes
await asyncio.sleep(5*60)
logging.error(f'Erreur check streamers online : {e}')
await asyncio.sleep(5 * 60)
def begin(self):
async def _runAnnouncements(self):
with webapp.app_context():
channel = ConfigurationHelper().getValue('twitch_channel')
while True:
try:
await checkAndSendAnnouncements(self.chat, channel, self.twitch)
except Exception as e:
logging.error(f'Erreur envoi annonces : {e}')
await asyncio.sleep(2 * 60) # Vérification toutes les 2 min pour limiter le spam
async def _checkClips(self):
"""Vérifie le clip le plus récent (polling) et notifie si nouveau."""
with webapp.app_context():
channel = ConfigurationHelper().getValue('twitch_channel')
if not channel:
return
from twitchAPI.helper import first
try:
user = await first(self.twitch.get_users(logins=[channel]))
if not user:
return
except Exception:
return
while True:
try:
with webapp.app_context():
from database.models import TwitchEventNotification
cfg = TwitchEventNotification.query.filter_by(event_type='clip', enable=True).first()
if not cfg:
await asyncio.sleep(120)
continue
clip = await first(self.twitch.get_clips(broadcaster_id=user.id, first=1))
if not clip:
await asyncio.sleep(120)
continue
if cfg.last_clip_id is None:
with webapp.app_context():
from database import db
cfg = TwitchEventNotification.query.filter_by(event_type='clip', enable=True).first()
if cfg:
cfg.last_clip_id = clip.id
db.session.commit()
elif clip.id != cfg.last_clip_id:
with webapp.app_context():
await event_notifications.notify_clip(
self.chat,
channel,
user=getattr(clip, 'creator_name', None) or getattr(clip, 'user_name', '') or clip.id,
title=getattr(clip, 'title', '') or 'Clip',
url=getattr(clip, 'url', '') or f'https://clips.twitch.tv/{clip.id}',
thumbnail_url=getattr(clip, 'thumbnail_url', '') or '',
clip_id=clip.id,
)
except Exception as e:
logging.error('Erreur check clips: %s', e)
await asyncio.sleep(120)
def begin(self):
asyncio.run(self._connect())
# je ne sais pas encore comment appeler ça
async def _close(self):
self.chat.stop()
await self.twitch.close()
twitchBot = TwitchBot()
twitchBot = TwitchBot()
+137
View File
@@ -0,0 +1,137 @@
import logging
from datetime import datetime, timedelta
from twitchAPI.chat import Chat
from twitchAPI.twitch import Twitch
from database import db
from database.models import TwitchAnnouncement
from webapp import webapp
logger = logging.getLogger('twitch-announcements')
logger.setLevel(logging.INFO)
# Délai minimum entre deux annonces (quelle qu'elles soient) pour éviter le spam
MIN_DELAY_BETWEEN_ANNOUNCEMENTS_MINUTES = 5
_message_count: int = 0
_last_announcement_index: int = -1 # Pour la rotation round-robin
def incrementMessageCount():
global _message_count
_message_count += 1
def _getAndResetMessageCount() -> int:
global _message_count
count = _message_count
_message_count = 0
return count
async def _is_channel_live(twitch: Twitch, channel: str) -> bool:
"""Vérifie si la chaîne Twitch est actuellement en live."""
try:
async for _ in twitch.get_streams(user_login=[channel]):
return True
except Exception as e:
logger.warning(f'Impossible de vérifier le statut live de {channel}: {e}')
return False
async def checkAndSendAnnouncements(chat: Chat, channel: str, twitch: Twitch):
"""
Envoie une annonce en rotation parmi celles dont la périodicité est écoulée,
uniquement si la chaîne est en live et qu'il y a assez d'activité dans le chat.
Appelé périodiquement par le bot Twitch.
"""
global _last_announcement_index
with webapp.app_context():
if not await _is_channel_live(twitch, channel):
return
announcements: list[TwitchAnnouncement] = TwitchAnnouncement.query.filter_by(enable=True).order_by(TwitchAnnouncement.id).all()
if not announcements:
return
now = datetime.now()
# Ne pas envoyer si une annonce (quelle qu'elle soit) a été envoyée récemment
last_any = max((a.last_sent for a in announcements if a.last_sent), default=None)
if last_any and (now - last_any) < timedelta(minutes=MIN_DELAY_BETWEEN_ANNOUNCEMENTS_MINUTES):
return
# Filtrer les annonces dont la périodicité est écoulée
due = [a for a in announcements if _shouldSend(a, now)]
if not due:
return
# Rotation round-robin : chercher la prochaine annonce après la dernière envoyée
announcement = _selectNextAnnouncement(due, announcements)
if not announcement:
return
# Vérifier le nombre minimum de messages dans le chat
message_count = _getAndResetMessageCount()
if message_count < announcement.min_chat_messages:
logger.debug(f'Annonce "{announcement.name}" ignorée : seulement {message_count} messages (minimum requis : {announcement.min_chat_messages})')
return
try:
await _sendAnnouncement(chat, channel, announcement)
announcement.last_sent = now
_last_announcement_index = announcements.index(announcement)
db.session.commit()
logger.info(f'Annonce envoyée : {announcement.name} (après {message_count} messages)')
except Exception as e:
logger.error(f'Erreur lors de l\'envoi de l\'annonce "{announcement.name}": {e}')
def _selectNextAnnouncement(due: list[TwitchAnnouncement], all_announcements: list[TwitchAnnouncement]) -> TwitchAnnouncement | None:
"""
Sélectionne la prochaine annonce selon un système de rotation round-robin.
Cherche la première annonce éligible après la dernière envoyée.
"""
global _last_announcement_index
if not due:
return None
# Si c'est la première annonce ou si l'index est invalide, prendre la première de la liste
if _last_announcement_index == -1 or _last_announcement_index >= len(all_announcements):
return due[0]
# Chercher la prochaine annonce éligible après la dernière envoyée
start_index = _last_announcement_index + 1
# Parcourir depuis l'index suivant jusqu'à la fin, puis revenir au début
for i in range(len(all_announcements)):
idx = (start_index + i) % len(all_announcements)
announcement = all_announcements[idx]
if announcement in due:
return announcement
# Fallback : retourner la première annonce éligible
return due[0]
def _shouldSend(announcement: TwitchAnnouncement, now: datetime) -> bool:
"""
Vérifie si une annonce doit être envoyée basée sur sa périodicité.
"""
if announcement.last_sent is None:
return True
time_since_last = now - announcement.last_sent
periodicity_delta = timedelta(minutes=announcement.periodicity)
return time_since_last >= periodicity_delta
async def _sendAnnouncement(chat: Chat, channel: str, announcement: TwitchAnnouncement):
"""
Envoie une annonce dans le chat Twitch.
"""
await chat.send_message(channel, announcement.text)
+264
View File
@@ -0,0 +1,264 @@
# Notifications d'événements Twitch (sub, follow, raid, clip) : chat + Discord
import asyncio
import logging
from typing import Any
import discord
from twitchAPI.chat import Chat
from twitchAPI.eventsub.websocket import EventSubWebsocket
from twitchAPI.object.eventsub import (
ChannelFollowEvent,
ChannelRaidEvent,
ChannelSubscribeEvent,
)
from twitchAPI.twitch import Twitch
from database import db
from database.models import TwitchEventNotification
from webapp import webapp
logger = logging.getLogger("twitch-events")
def _format_message(template: str, **kwargs: Any) -> str:
if not template:
return ""
for k, v in (kwargs or {}).items():
template = template.replace("{" + k + "}", str(v or ""))
return template
async def _send_twitch(chat: Chat, channel: str, text: str) -> None:
if not text or not chat:
return
try:
await chat.send_message(channel, text[:500])
except Exception as e:
logger.error("Envoi chat Twitch événement: %s", e)
def _schedule_discord_send(channel_id: int, content: str | None, embed: discord.Embed | None) -> None:
"""Planifie l'envoi sur le canal Discord (sans bloquer le loop Twitch)."""
from discordbot import bot
try:
ch = bot.get_channel(channel_id)
if not ch:
logger.warning("Canal Discord %s introuvable", channel_id)
return
payload = content if content else embed
if not payload:
return
asyncio.run_coroutine_threadsafe(
ch.send(content=content, embed=embed) if (content and embed) else ch.send(content=content or None, embed=embed if not content else None),
bot.loop,
)
except Exception as e:
logger.error("Envoi Discord événement: %s", e)
async def _handle_follow(data: ChannelFollowEvent, chat: Chat, channel: str) -> None:
with webapp.app_context():
cfg = TwitchEventNotification.query.filter_by(event_type="follow", enable=True).first()
if not cfg:
return
ev = data.event
user = getattr(ev, "user_name", None) or getattr(ev, "user_login", "")
user_login = getattr(ev, "user_login", user)
msg = _format_message(
cfg.message_twitch or "Merci {user} pour le follow !",
user=user_login,
user_name=user,
)
if cfg.notify_twitch_chat and msg:
await _send_twitch(chat, channel, msg)
if cfg.notify_discord and cfg.discord_channel_id:
content = _format_message(cfg.message_discord or "", user=user_login, user_name=user)
try:
embed_color = int(cfg.embed_color or "9146FF", 16)
except ValueError:
embed_color = 0x9146FF
embed = discord.Embed(
title=_format_message(cfg.embed_title or "Nouveau follow", user=user_login, user_name=user),
description=cfg.embed_description or f"{user} suit maintenant la chaîne.",
color=embed_color,
)
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
async def _handle_subscribe(data: ChannelSubscribeEvent, chat: Chat, channel: str) -> None:
with webapp.app_context():
cfg = TwitchEventNotification.query.filter_by(event_type="sub", enable=True).first()
if not cfg:
return
ev = data.event
user = getattr(ev, "user_name", None) or getattr(ev, "user_login", "")
user_login = getattr(ev, "user_login", user)
msg = _format_message(
cfg.message_twitch or "Merci {user} pour l'abonnement !",
user=user_login,
user_name=user,
)
if cfg.notify_twitch_chat and msg:
await _send_twitch(chat, channel, msg)
if cfg.notify_discord and cfg.discord_channel_id:
content = _format_message(cfg.message_discord or "", user=user_login, user_name=user)
try:
embed_color = int(cfg.embed_color or "9146FF", 16)
except ValueError:
embed_color = 0x9146FF
embed = discord.Embed(
title=_format_message(cfg.embed_title or "Nouvel abonnement", user=user_login, user_name=user),
description=cfg.embed_description or f"Merci à {user} pour l'abonnement !",
color=embed_color,
)
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
async def _handle_raid(data: ChannelRaidEvent, chat: Chat, channel: str) -> None:
with webapp.app_context():
cfg = TwitchEventNotification.query.filter_by(event_type="raid", enable=True).first()
if not cfg:
return
ev = data.event
from_broadcaster = getattr(ev, "from_broadcaster_user_name", None) or getattr(ev, "from_broadcaster_user_login", "")
viewers = getattr(ev, "viewers", 0)
msg = _format_message(
cfg.message_twitch or "Bienvenue aux {viewers} viewers de {from_broadcaster_name} !",
from_broadcaster_name=from_broadcaster,
viewers=viewers,
)
if cfg.notify_twitch_chat and msg:
await _send_twitch(chat, channel, msg)
if cfg.notify_discord and cfg.discord_channel_id:
content = _format_message(
cfg.message_discord or "",
from_broadcaster_name=from_broadcaster,
viewers=viewers,
)
try:
embed_color = int(cfg.embed_color or "9146FF", 16)
except ValueError:
embed_color = 0x9146FF
embed = discord.Embed(
title=_format_message(
cfg.embed_title or "Raid reçu",
from_broadcaster_name=from_broadcaster,
viewers=viewers,
),
description=cfg.embed_description or f"{from_broadcaster} a raid avec {viewers} viewers !",
color=embed_color,
)
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
async def notify_clip(
chat: Chat | None,
channel: str,
*,
user: str,
title: str,
url: str,
thumbnail_url: str,
clip_id: str,
) -> None:
"""Appelé quand un nouveau clip est détecté (polling)."""
with webapp.app_context():
cfg = TwitchEventNotification.query.filter_by(event_type="clip", enable=True).first()
if not cfg:
return
msg_twitch = _format_message(
cfg.message_twitch or "Nouveau clip par {user} : {title} {url}",
user=user,
title=title,
url=url,
)
if cfg.notify_twitch_chat and chat and msg_twitch:
await _send_twitch(chat, channel, msg_twitch)
if cfg.notify_discord and cfg.discord_channel_id:
content = _format_message(
cfg.message_discord or "",
user=user,
title=title,
url=url,
thumbnail_url=thumbnail_url or "",
)
try:
embed_color = int(cfg.embed_color or "9146FF", 16)
except ValueError:
embed_color = 0x9146FF
embed = discord.Embed(
title=_format_message(cfg.embed_title or "Nouveau clip", user=user, title=title),
url=url,
description=cfg.embed_description or title,
color=embed_color,
)
if cfg.embed_thumbnail and thumbnail_url:
embed.set_thumbnail(url=thumbnail_url)
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
cfg.last_clip_id = clip_id
db.session.commit()
def create_eventsub(twitch: Twitch, callback_loop: asyncio.AbstractEventLoop) -> EventSubWebsocket:
"""Crée et démarre le client EventSub. Les callbacks seront exécutés sur `callback_loop`."""
eventsub = EventSubWebsocket(twitch, callback_loop=callback_loop)
eventsub.start()
return eventsub
async def register_eventsub_handlers(
eventsub: EventSubWebsocket,
broadcaster_id: str,
chat: Chat,
channel: str,
) -> None:
"""Enregistre follow, sub, raid sur l'EventSub. À appeler dans les 10 s après start()."""
# Définir les callbacks comme des wrappers explicites
async def on_follow(data: ChannelFollowEvent) -> None:
try:
await _handle_follow(data, chat, channel)
except Exception as e:
logger.error("Erreur handler follow: %s", e)
async def on_subscribe(data: ChannelSubscribeEvent) -> None:
try:
await _handle_subscribe(data, chat, channel)
except Exception as e:
logger.error("Erreur handler subscribe: %s", e)
async def on_raid(data: ChannelRaidEvent) -> None:
try:
await _handle_raid(data, chat, channel)
except Exception as e:
logger.error("Erreur handler raid: %s", e)
# Chaque souscription est tentée séparément : si le token n'a pas channel:read:subscriptions,
# seule "sub" échouera ; follow et raid restent actifs.
subscriptions_ok = 0
try:
await eventsub.listen_channel_follow_v2(broadcaster_id, broadcaster_id, on_follow)
logger.info("EventSub: follow enregistré ✓")
subscriptions_ok += 1
except Exception as e:
logger.error("EventSub follow: %s", e)
try:
await eventsub.listen_channel_subscribe(broadcaster_id, on_subscribe)
logger.info("EventSub: subscribe enregistré ✓")
subscriptions_ok += 1
except Exception as e:
logger.warning("EventSub subscribe (nécessite scope channel:read:subscriptions): %s", e)
try:
await eventsub.listen_channel_raid(to_broadcaster_user_id=broadcaster_id, callback=on_raid)
logger.info("EventSub: raid enregistré ✓")
subscriptions_ok += 1
except Exception as e:
logger.error("EventSub raid: %s", e)
if subscriptions_ok == 0:
logger.error("EventSub: AUCUNE souscription n'a réussi ! Le WebSocket va se fermer.")
else:
logger.info(f"EventSub: {subscriptions_ok}/3 souscriptions actives")
+166
View File
@@ -0,0 +1,166 @@
import re
import logging
from datetime import datetime, timedelta
from twitchAPI.twitch import Twitch
from twitchAPI.chat import ChatMessage
from database import db
from database.models import TwitchLinkFilter, TwitchAllowedDomain, TwitchPermit, TwitchAllowedUser
from twitchbot.moderation import _log_action, _get_broadcaster_id, _get_moderator_id, _get_user_id, _is_moderator
from webapp import webapp
logger = logging.getLogger('twitch-link-filter')
logger.setLevel(logging.INFO)
URL_REGEX = re.compile(r'https?://[^\s]+|(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,}(?:/[^\s]*)?')
def _get_filter_config():
"""Retourne un dictionnaire avec la config du filtre de liens"""
with webapp.app_context():
config = TwitchLinkFilter.query.first()
if not config:
config = TwitchLinkFilter(enabled=False)
db.session.add(config)
db.session.commit()
# Retourner un dict pour éviter DetachedInstanceError
return {
'enabled': config.enabled,
'allow_subscribers': config.allow_subscribers,
'allow_vips': config.allow_vips,
'allow_moderators': config.allow_moderators,
'timeout_duration': config.timeout_duration,
'warning_message': config.warning_message
}
def _get_allowed_domains():
with webapp.app_context():
return [d.domain.lower() for d in TwitchAllowedDomain.query.all()]
def _is_user_whitelisted(username: str) -> bool:
with webapp.app_context():
return TwitchAllowedUser.query.filter_by(username=username.lower()).first() is not None
def _has_valid_permit(username: str) -> bool:
with webapp.app_context():
permit = TwitchPermit.query.filter_by(username=username.lower()).first()
if permit and permit.expires_at > datetime.now():
return True
if permit and permit.expires_at <= datetime.now():
db.session.delete(permit)
db.session.commit()
return False
def _extract_domain(url: str) -> str:
url = url.lower()
url = re.sub(r'^https?://', '', url)
url = re.sub(r'^www\.', '', url)
return url.split('/')[0]
def _is_domain_allowed(url: str, allowed_domains: list) -> bool:
domain = _extract_domain(url)
for allowed in allowed_domains:
if domain == allowed or domain.endswith('.' + allowed):
return True
return False
async def check_message_for_links(msg: ChatMessage, twitch: Twitch) -> bool:
config = _get_filter_config()
if not config['enabled']:
return True
if config['allow_moderators'] and (msg.user.mod or msg.user.name.lower() == msg.room.name.lower()):
return True
if config['allow_vips'] and msg.user.vip:
return True
if config['allow_subscribers'] and msg.user.subscriber:
return True
if _is_user_whitelisted(msg.user.name):
return True
urls = URL_REGEX.findall(msg.text)
if not urls:
return True
if _has_valid_permit(msg.user.name):
with webapp.app_context():
permit = TwitchPermit.query.filter_by(username=msg.user.name.lower()).first()
if permit:
db.session.delete(permit)
db.session.commit()
return True
allowed_domains = _get_allowed_domains()
for url in urls:
if not _is_domain_allowed(url, allowed_domains):
await _handle_unauthorized_link(msg, twitch, config, url)
return False
return True
async def _handle_unauthorized_link(msg: ChatMessage, twitch: Twitch, config: dict, url: str):
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
user_id = await _get_user_id(twitch, msg.user.name)
if user_id and config['timeout_duration'] > 0:
try:
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason="Lien non autorise", duration=config['timeout_duration'])
except Exception as e:
logger.error(f"Erreur timeout link filter: {e}")
try:
await twitch.delete_chat_messages(broadcaster_id, moderator_id, message_id=msg.id)
except Exception as e:
logger.error(f"Erreur suppression message: {e}")
if config['warning_message']:
await msg.reply(config['warning_message'])
_log_action("link_blocked", "AutoMod", msg.user.name, _extract_domain(url))
logger.info(f"Lien bloque de {msg.user.name}: {url}")
async def permit_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !permit <viewer> [minutes]")
return
username = args[0].lstrip('@').lower()
duration = 60
if len(args) >= 2:
try:
duration = int(args[1]) * 60
except ValueError:
duration = 60
expires_at = datetime.now() + timedelta(seconds=duration)
with webapp.app_context():
existing = TwitchPermit.query.filter_by(username=username).first()
if existing:
existing.expires_at = expires_at
else:
permit = TwitchPermit(username=username, expires_at=expires_at)
db.session.add(permit)
db.session.commit()
_log_action("permit", msg.user.name, username, f"{duration}s")
await msg.reply(f"@{username} peut poster un lien pendant {duration // 60} minute(s)")
logger.info(f"Permit accorde a {username} par {msg.user.name}")
+155 -12
View File
@@ -1,4 +1,5 @@
import logging
import discord
from twitchAPI.twitch import Twitch
from twitchAPI.object.api import Stream
@@ -11,11 +12,76 @@ from webapp import webapp
logger = logging.getLogger('live-alert')
logger.setLevel(logging.INFO)
_live_alert_first_check = True
def _stream_thumbnail_url(stream: Stream) -> str:
"""URL de la miniature du stream (preview Twitch)."""
url = getattr(stream, 'thumbnail_url', None) or ''
if '{width}' in url or '{height}' in url:
url = url.replace('{width}', '320').replace('{height}', '180')
if not url:
url = f"https://static-cdn.jtvnw.net/previews-ttv/live_user_{stream.user_login}-320x180.jpg"
return url
def _format_embed_text(text: str, stream: Stream, stream_url: str, thumbnail: str) -> str:
"""Formate un texte d'embed avec les variables stream."""
if not text:
return ''
try:
return text.format(
user_login=stream.user_login or '',
user_name=stream.user_name or '',
game_name=getattr(stream, 'game_name', None) or '',
title=stream.title or '',
language=getattr(stream, 'language', None) or '',
stream_url=stream_url,
thumbnail=thumbnail,
)
except KeyError:
return text
async def checkOnlineStreamer(twitch: Twitch) :
global _live_alert_first_check
with webapp.app_context() :
alerts : list[LiveAlert] = LiveAlert.query.all()
streams = await _retreiveStreams(twitch, alerts)
watch_stream = None
# Récupération du statut du live principal (channel configuré)
from database.helpers import ConfigurationHelper
main_channel = ConfigurationHelper().getValue('twitch_channel')
main_stream = None
if main_channel:
main_stream = next((s for s in streams if s.user_login.lower() == main_channel.lower()), None)
# Mise à jour du BOT_STATUS pour la webapp
if main_stream:
webapp.config["BOT_STATUS"]["twitch_is_live"] = True
webapp.config["BOT_STATUS"]["twitch_viewer_count"] = getattr(main_stream, 'viewer_count', 0)
else:
webapp.config["BOT_STATUS"]["twitch_is_live"] = False
webapp.config["BOT_STATUS"]["twitch_viewer_count"] = 0
# Premier check : synchronisation sans notification
if _live_alert_first_check:
logger.info('Live Alert: première vérification, synchronisation sans notification')
for alert in alerts:
stream = next((s for s in streams if s.user_login == alert.login), None)
if stream:
alert.online = True
if alert.watch_activity and alert.enable:
watch_stream = stream
else:
alert.online = False
await _updateBotActivity(watch_stream)
db.session.commit()
_live_alert_first_check = False
return
# Vérifications normales ensuite
for alert in alerts :
stream = next((s for s in streams if s.user_login == alert.login), None)
if stream :
@@ -24,26 +90,103 @@ async def checkOnlineStreamer(twitch: Twitch) :
logger.info(f'N\'etait pas en ligne auparavant : {alert.login}')
await _notifyAlert(alert, stream)
alert.online = True
if alert.watch_activity and alert.enable:
watch_stream = stream
else :
logger.info(f'Streamer hors ligne : {alert.login}')
alert.online = False
await _updateBotActivity(watch_stream)
db.session.commit()
async def _notifyAlert(alert : LiveAlert, stream : Stream):
message : str = alert.message.format(stream)
logger.info(f'Message de notification : {message}')
bot.loop.create_task(_sendMessage(alert.notify_channel, message))
async def _sendMessage(channel : int, message : str) :
logger.info(f'Envoi de notification : {message}')
await bot.get_channel(channel).send(message)
logger.info(f'Notification envoyé')
async def _updateBotActivity(stream: Stream | None):
if stream:
logger.info(f'Mise à jour de l\'activité : Regarde le live de {stream.user_name}')
activity = discord.Streaming(
name=f'Regarde le live de {stream.user_name}',
url=f'https://www.twitch.tv/{stream.user_login}'
)
await bot.change_presence(status=discord.Status.online, activity=activity)
else:
logger.info('Aucun stream à regarder, retour à l\'activité normale')
# Remettre une humeur aléatoire
from database.models import Humeur
import random
humeurs = Humeur.query.all()
if humeurs:
humeur = random.choice(humeurs)
logger.info(f'Réinitialisation du statut : {humeur.text}')
await bot.change_presence(status=discord.Status.online, activity=discord.CustomActivity(humeur.text))
else:
# Si pas de humeur, remettre un statut par défaut
await bot.change_presence(status=discord.Status.online, activity=None)
async def _retreiveStreams(twitch: Twitch, alerts : list[LiveAlert]) -> list[Stream] :
streams : list[Stream] = []
async def _notifyAlert(alert: LiveAlert, stream: Stream):
stream_url = f'https://www.twitch.tv/{stream.user_login}'
thumbnail = _stream_thumbnail_url(stream)
# Message texte optionnel (avant l'embed)
message_text = None
if alert.message and alert.message.strip():
try:
message_text = alert.message.format(stream)
except KeyError:
message_text = alert.message
# Construction de l'embed Discord
try:
embed_color = int(alert.embed_color or '9146FF', 16)
except ValueError:
embed_color = 0x9146FF
embed_title = _format_embed_text(alert.embed_title, stream, stream_url, thumbnail) if alert.embed_title else (stream.title or f"{stream.user_name} est en live")
embed_description = _format_embed_text(alert.embed_description, stream, stream_url, thumbnail) if alert.embed_description else None
embed = discord.Embed(
title=embed_title,
url=stream_url,
color=embed_color
)
if embed_description:
embed.description = embed_description
author_name = _format_embed_text(alert.embed_author_name, stream, stream_url, thumbnail) if alert.embed_author_name else stream.user_name
user_id = getattr(stream, 'user_id', None)
author_icon = alert.embed_author_icon or (f"https://static-cdn.jtvnw.net/jtv_user_pictures/{user_id}-profile_image-70x70.png" if user_id else "https://static-cdn.jtvnw.net/ttv-favicon/favicon-32x32.png")
embed.set_author(name=author_name, icon_url=author_icon)
if alert.embed_thumbnail and thumbnail:
embed.set_thumbnail(url=thumbnail)
if alert.embed_image and thumbnail:
embed.set_image(url=thumbnail)
if alert.embed_footer:
footer_text = _format_embed_text(alert.embed_footer, stream, stream_url, thumbnail)
if footer_text:
embed.set_footer(text=footer_text)
logger.info(f'Envoi de notification live (embed) : {stream.user_login}')
bot.loop.create_task(_sendMessage(alert.notify_channel, message_text, embed))
async def _sendMessage(channel_id: int, message: str | None, embed: discord.Embed):
try:
discord_channel = bot.get_channel(channel_id)
if not discord_channel:
logger.error(f"Canal Discord {channel_id} introuvable")
return
if message and message.strip():
await discord_channel.send(content=message, embed=embed)
else:
await discord_channel.send(embed=embed)
logger.info('Notification live envoyée')
except Exception as e:
logger.error(f"Erreur lors de l'envoi de la notification live : {e}")
async def _retreiveStreams(twitch: Twitch, alerts: list[LiveAlert]) -> list[Stream]:
streams: list[Stream] = []
logger.info(f'Recherche de streams pour : {alerts}')
async for stream in twitch.get_streams(user_login = [alert.login for alert in alerts]):
async for stream in twitch.get_streams(user_login=[alert.login for alert in alerts]):
streams.append(stream)
logger.info(f'Ces streams sont en ligne : {streams}')
return streams
+446
View File
@@ -0,0 +1,446 @@
import re
import logging
from datetime import datetime
from twitchAPI.twitch import Twitch
from twitchAPI.chat import ChatMessage
from database import db
from database.models import TwitchAnnouncement, TwitchModerationLog, TwitchBannedWord
from webapp import webapp
logger = logging.getLogger('twitch-moderation')
logger.setLevel(logging.INFO)
last_multitwitch: str = None
games_disabled: bool = False
def _log_action(action: str, moderator: str, target: str = None, details: str = None):
with webapp.app_context():
log = TwitchModerationLog(
action=action,
moderator=moderator,
target=target,
details=details,
created_at=datetime.now()
)
db.session.add(log)
db.session.commit()
def _is_moderator(msg: ChatMessage) -> bool:
return msg.user.mod or msg.user.name.lower() == msg.room.name.lower()
async def _get_broadcaster_id(twitch: Twitch, channel: str) -> str:
async for user in twitch.get_users(logins=[channel]):
return user.id
return None
async def _get_user_id(twitch: Twitch, username: str) -> str:
async for user in twitch.get_users(logins=[username]):
return user.id
return None
async def _get_moderator_id(twitch: Twitch) -> str:
async for user in twitch.get_users():
return user.id
return None
async def timeout_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !timeout <viewer> [minutes] [raison]")
return
viewer = args[0].lstrip('@')
duration = 180 # 3 minutes par défaut
reason = "Timeout"
# Si args[1] est un nombre, c'est la durée, sinon c'est la raison
if len(args) >= 2:
try:
duration = int(args[1]) * 60
# Tout ce qui suit est la raison
if len(args) >= 3:
reason = ' '.join(args[2:])
except ValueError:
# args[1] n'est pas un nombre, donc tout depuis args[1] est la raison
reason = ' '.join(args[1:])
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason, duration=duration)
# Log avec durée et raison
log_details = f"{duration}s - {reason}"
_log_action("timeout", msg.user.name, viewer, log_details)
logger.info(f'{viewer} timeout pour {duration}s par {msg.user.name} - Raison: {reason}')
async def ban_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !ban <viewer1> [viewer2] ...")
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
for viewer in args[:5]:
viewer = viewer.lstrip('@')
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason="Ban")
_log_action("ban", msg.user.name, viewer)
logger.info(f'{viewer} banni par {msg.user.name}')
async def unban_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !unban <viewer1> [viewer2] ...")
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
for viewer in args[:5]:
viewer = viewer.lstrip('@')
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.unban_user(broadcaster_id, moderator_id, user_id)
_log_action("unban", msg.user.name, viewer)
logger.info(f'{viewer} débanni par {msg.user.name}')
async def clean_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
if len(args) >= 1:
viewer = args[0].lstrip('@')
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.delete_chat_messages(broadcaster_id, moderator_id, user_id=user_id)
_log_action("clean", msg.user.name, viewer)
logger.info(f'Messages de {viewer} supprimés par {msg.user.name}')
else:
await twitch.delete_chat_messages(broadcaster_id, moderator_id)
_log_action("clean", msg.user.name, None, "Chat complet")
logger.info(f'Chat nettoyé par {msg.user.name}')
async def shieldmode_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !shieldmode <on/off>")
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
is_active = args[0].lower() == "on"
await twitch.update_shield_mode_status(broadcaster_id, moderator_id, is_active)
_log_action("shieldmode", msg.user.name, None, "on" if is_active else "off")
logger.info(f'Shield mode {"activé" if is_active else "désactivé"} par {msg.user.name}')
async def settitle_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
parts = msg.text.split(maxsplit=1)
if len(parts) < 2:
await msg.reply("Usage: !settitle <titre>")
return
title = parts[1]
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
await twitch.modify_channel_information(broadcaster_id, title=title)
_log_action("settitle", msg.user.name, None, title)
logger.info(f'Titre changé en "{title}" par {msg.user.name}')
async def setgame_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
parts = msg.text.split(maxsplit=1)
if len(parts) < 2:
await msg.reply("Usage: !setgame <jeu>")
return
game_name = parts[1]
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
game_id = None
async for game in twitch.get_games(names=[game_name]):
game_id = game.id
break
if game_id:
await twitch.modify_channel_information(broadcaster_id, game_id=game_id)
_log_action("setgame", msg.user.name, None, game_name)
logger.info(f'Jeu changé en "{game_name}" par {msg.user.name}')
else:
await msg.reply(f"Jeu '{game_name}' introuvable")
async def subon_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=True)
_log_action("subon", msg.user.name)
logger.info(f'Mode abonnés activé par {msg.user.name}')
async def suboff_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=False)
_log_action("suboff", msg.user.name)
logger.info(f'Mode abonnés désactivé par {msg.user.name}')
async def follon_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
duration = 10
if len(args) >= 1:
try:
duration = int(args[0])
except ValueError:
duration = 10
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, follower_mode=True, follower_mode_duration=duration)
_log_action("follon", msg.user.name, None, f"{duration}min")
logger.info(f'Mode followers ({duration}min) activé par {msg.user.name}')
async def folloff_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, follower_mode=False)
_log_action("folloff", msg.user.name)
logger.info(f'Mode followers désactivé par {msg.user.name}')
async def emoteon_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=True)
_log_action("emoteon", msg.user.name)
logger.info(f'Mode emote activé par {msg.user.name}')
async def emoteoff_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=False)
_log_action("emoteoff", msg.user.name)
logger.info(f'Mode emote désactivé par {msg.user.name}')
async def multitwitch_command(msg: ChatMessage, twitch: Twitch):
global last_multitwitch
args = msg.text.split()[1:]
if len(args) == 0:
if last_multitwitch:
await msg.reply(last_multitwitch)
return
if not _is_moderator(msg):
return
if args[0].lower() == "reset":
last_multitwitch = None
logger.info(f'MultiTwitch reset par {msg.user.name}')
return
if args[0].lower() == "auto":
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
async for channel in twitch.get_channel_information(broadcaster_id):
title = channel.title
mentions = re.findall(r'@(\w+)', title)
if mentions:
channels = [msg.room.name] + mentions
last_multitwitch = f"https://multitwitch.live/{'/'.join(channels)}"
await msg.reply(last_multitwitch)
return
return
channels = []
for arg in args:
if arg == "@":
channels.append(msg.room.name)
else:
channels.append(arg.lstrip('@'))
last_multitwitch = f"https://multitwitch.live/{'/'.join(channels)}"
await msg.reply(last_multitwitch)
logger.info(f'MultiTwitch créé par {msg.user.name}: {last_multitwitch}')
async def ann_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 2:
await msg.reply("Usage: !ann <alias> <on/off/toggle>")
return
alias = args[0]
action = args[1].lower()
with webapp.app_context():
announcement = TwitchAnnouncement.query.filter_by(name=alias).first()
if not announcement:
await msg.reply(f"Annonce '{alias}' introuvable")
return
if action == "on":
announcement.enable = True
elif action == "off":
announcement.enable = False
elif action == "toggle":
announcement.enable = not announcement.enable
else:
await msg.reply("Action invalide: on/off/toggle")
return
db.session.commit()
status = "activée" if announcement.enable else "désactivée"
logger.info(f'Annonce {alias} {status} par {msg.user.name}')
await msg.reply(f"Annonce '{alias}' {status}")
async def no_game_command(msg: ChatMessage, twitch: Twitch):
global games_disabled
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !no_game <on/off>")
return
action = args[0].lower()
if action == "on":
games_disabled = True
logger.info(f'Jeux désactivés par {msg.user.name}')
await msg.reply("Jeux désactivés")
elif action == "off":
games_disabled = False
logger.info(f'Jeux activés par {msg.user.name}')
await msg.reply("Jeux activés")
def are_games_disabled() -> bool:
return games_disabled
async def check_message_for_banned_words(msg: ChatMessage, twitch: Twitch) -> bool:
"""
Vérifie si le message contient des mots interdits.
Retourne True si le message est valide, False s'il doit être bloqué.
"""
# Modérateurs et broadcaster exemptés
if msg.user.mod or msg.user.name.lower() == msg.room.name.lower():
return True
with webapp.app_context():
banned_words = TwitchBannedWord.query.filter_by(enabled=True).all()
if not banned_words:
return True
message_lower = msg.text.lower()
for banned_word_entry in banned_words:
word = banned_word_entry.word.lower()
# Recherche du mot dans le message (mot entier ou partie de mot)
if word in message_lower:
# Bloquer le message
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
user_id = await _get_user_id(twitch, msg.user.name)
# Timeout de l'utilisateur
if user_id and banned_word_entry.timeout_duration > 0:
try:
await twitch.ban_user(
broadcaster_id,
moderator_id,
user_id,
reason=f"Mot interdit: {banned_word_entry.word}",
duration=banned_word_entry.timeout_duration
)
except Exception as e:
logger.error(f"Erreur timeout mot interdit: {e}")
# Suppression du message
try:
await twitch.delete_chat_messages(broadcaster_id, moderator_id, message_id=msg.id)
except Exception as e:
logger.error(f"Erreur suppression message mot interdit: {e}")
# Log
_log_action("banned_word", "AutoMod", msg.user.name, f"Mot: {banned_word_entry.word}")
logger.info(f"Mot interdit détecté de {msg.user.name}: {banned_word_entry.word}")
return False
return True
+53 -1
View File
@@ -1,5 +1,57 @@
import os
from flask import Flask
from flask_login import LoginManager
webapp = Flask(__name__)
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation
# Secret key pour les sessions (Flask-Login)
webapp.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-production")
# État des bots (mis à jour par les bots, lu par le panneau)
webapp.config["BOT_STATUS"] = {
"discord_connected": False,
"discord_guild_count": 0,
"twitch_connected": False,
"twitch_channel_name": None,
"twitch_is_live": False,
"twitch_viewer_count": 0,
"twitch_chat_messages": [], # Derniers messages du chat (max 100)
}
login_manager = LoginManager()
login_manager.init_app(webapp)
login_manager.login_view = "login"
login_manager.login_message = "Veuillez vous connecter pour accéder à cette page."
from database.models import WebappUser
@login_manager.user_loader
def load_user(user_id):
try:
return WebappUser.query.get(int(user_id))
except (ValueError, TypeError):
return None
from webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot
from flask import request, redirect, url_for
from flask_login import current_user
@webapp.context_processor
def inject_user_level():
from flask_login import current_user
from database.helpers import ConfigurationHelper
reg = ConfigurationHelper().getValue("registration_enabled")
registration_enabled = reg not in (None, "", "false", "0", "no", "off")
return {
"current_user_level": current_user.get_level() if current_user.is_authenticated else -1,
"registration_enabled": registration_enabled,
}
@webapp.before_request
def require_login():
"""Redirige vers /login si non authentifié (sauf login, register, static, callback Twitch OAuth)."""
if request.endpoint in (None, "login", "register", "static", "twitchReceiveToken"):
return
if not current_user.is_authenticated:
return redirect(url_for("login", next=request.url))
+84
View File
@@ -0,0 +1,84 @@
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import TwitchAnnouncement
@webapp.route("/announcements")
@require_page("announcements")
def openAnnouncements():
announcements = TwitchAnnouncement.query.all()
return render_template("announcements.html", announcements=announcements)
@webapp.route("/announcements/add", methods=['POST'])
@require_page("announcements")
def addAnnouncement():
if not can_write_page("announcements"):
return render_template("403.html"), 403
announcement = TwitchAnnouncement(
enable=True,
name=request.form.get('name'),
text=request.form.get('text'),
periodicity=int(request.form.get('periodicity', 10)),
min_chat_messages=int(request.form.get('min_chat_messages', 0))
)
db.session.add(announcement)
db.session.commit()
return redirect(url_for("openAnnouncements"))
@webapp.route("/announcements/toggle/<int:id>")
@require_page("announcements")
def toggleAnnouncement(id):
if not can_write_page("announcements"):
return render_template("403.html"), 403
announcement = TwitchAnnouncement.query.get_or_404(id)
announcement.enable = not announcement.enable
db.session.commit()
return redirect(url_for("openAnnouncements"))
@webapp.route("/announcements/edit/<int:id>")
@require_page("announcements")
def openEditAnnouncement(id):
announcement = TwitchAnnouncement.query.get_or_404(id)
return render_template("announcements.html", announcement=announcement)
@webapp.route("/announcements/edit/<int:id>", methods=['POST'])
@require_page("announcements")
def submitEditAnnouncement(id):
if not can_write_page("announcements"):
return render_template("403.html"), 403
announcement = TwitchAnnouncement.query.get_or_404(id)
announcement.name = request.form.get('name')
announcement.text = request.form.get('text')
announcement.periodicity = int(request.form.get('periodicity', 10))
announcement.min_chat_messages = int(request.form.get('min_chat_messages', 0))
db.session.commit()
return redirect(url_for("openAnnouncements"))
@webapp.route("/announcements/del/<int:id>")
@require_page("announcements")
def delAnnouncement(id):
if not can_write_page("announcements"):
return render_template("403.html"), 403
announcement = TwitchAnnouncement.query.get_or_404(id)
db.session.delete(announcement)
db.session.commit()
return redirect(url_for("openAnnouncements"))
@webapp.route("/announcements/reset/<int:id>")
@require_page("announcements")
def resetAnnouncement(id):
if not can_write_page("announcements"):
return render_template("403.html"), 403
announcement = TwitchAnnouncement.query.get_or_404(id)
announcement.last_sent = None
db.session.commit()
return redirect(url_for("openAnnouncements"))
+164
View File
@@ -0,0 +1,164 @@
# Authentification webapp : login, register, logout et contrôle d'accès par rôles.
from functools import wraps
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from database import db
from database.models import WebappUser, ROLE_ORDER, PagePermission
from database.helpers import ConfigurationHelper
from webapp import webapp
def require_roles(allowed_roles: list):
"""Décorateur : exige que l'utilisateur soit authentifié et ait l'un des rôles autorisés."""
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for("login", next=request.url))
if current_user.role not in allowed_roles:
return render_template("403.html"), 403
return f(*args, **kwargs)
return wrapped
return decorator
def require_role_min(min_role: str):
"""Décorateur : exige que l'utilisateur ait au moins le rôle min_role (niveau en base)."""
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for("login", next=request.url))
if not current_user.has_role_at_least(min_role):
return render_template("403.html"), 403
return f(*args, **kwargs)
return wrapped
return decorator
def _page_min_level(page_key: str, for_write: bool = False) -> int:
"""Niveau minimum requis pour la page (lecture ou écriture)."""
perm = PagePermission.query.filter_by(page_key=page_key).first()
if not perm:
return 0
if for_write and perm.write_level is not None:
return perm.write_level
return perm.min_level
def require_page(page_key: str):
"""Décorateur : accès en lecture selon les permissions de la page (webapp_page_permission)."""
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for("login", next=request.url))
min_level = _page_min_level(page_key, for_write=False)
if not current_user.has_level_at_least(min_level):
return render_template("403.html"), 403
return f(*args, **kwargs)
return wrapped
return decorator
def require_page_write(page_key: str):
"""Décorateur : accès en écriture selon les permissions de la page."""
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for("login", next=request.url))
min_level = _page_min_level(page_key, for_write=True)
if not current_user.has_level_at_least(min_level):
return render_template("403.html"), 403
return f(*args, **kwargs)
return wrapped
return decorator
def can_write_page(page_key: str) -> bool:
"""Retourne True si l'utilisateur connecté a le niveau pour écrire sur cette page."""
if not current_user.is_authenticated:
return False
return current_user.has_level_at_least(_page_min_level(page_key, for_write=True))
@webapp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("index"))
if request.method == "POST":
identifier = (request.form.get("identifier") or "").strip()
password = request.form.get("password") or ""
if not identifier or not password:
flash("Identifiant et mot de passe requis.", "error")
return render_template("login.html")
user = WebappUser.query.filter(
(WebappUser.username == identifier) | (WebappUser.email == identifier)
).first()
if user and check_password_hash(user.password_hash, password):
login_user(user, remember=True)
next_url = request.args.get("next")
if next_url and next_url.startswith("/"):
return redirect(next_url)
return redirect(url_for("index"))
flash("Identifiant ou mot de passe incorrect.", "error")
return render_template("login.html")
return render_template("login.html")
@webapp.route("/register", methods=["GET", "POST"])
def register():
if current_user.is_authenticated:
return redirect(url_for("index"))
# Inscriptions désactivées par le super admin
reg_enabled = ConfigurationHelper().getValue("registration_enabled")
if reg_enabled in (None, "", "false", "0", "no", "off"):
flash("Les inscriptions sont désactivées.", "error")
return redirect(url_for("login"))
if request.method == "POST":
username = (request.form.get("username") or "").strip()
email = (request.form.get("email") or "").strip().lower()
password = request.form.get("password") or ""
password_confirm = request.form.get("password_confirm") or ""
errors = []
if len(username) < 3:
errors.append("Le nom d'utilisateur doit faire au moins 3 caractères.")
if len(email) < 5 or "@" not in email:
errors.append("Adresse e-mail invalide.")
if len(password) < 8:
errors.append("Le mot de passe doit faire au moins 8 caractères.")
if password != password_confirm:
errors.append("Les mots de passe ne correspondent pas.")
if WebappUser.query.filter_by(username=username).first():
errors.append("Ce nom d'utilisateur est déjà pris.")
if WebappUser.query.filter_by(email=email).first():
errors.append("Cette adresse e-mail est déjà utilisée.")
if errors:
for msg in errors:
flash(msg, "error")
return render_template("register.html")
# Premier inscrit = super administrateur
role = "super_administrateur" if WebappUser.query.count() == 0 else "viewer_twitch"
user = WebappUser(
username=username,
email=email,
password_hash=generate_password_hash(password, method="scrypt"),
role=role,
)
db.session.add(user)
db.session.commit()
flash("Compte créé. Vous pouvez vous connecter.", "success")
return redirect(url_for("login"))
return render_template("register.html")
@webapp.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("login"))
+22 -2
View File
@@ -1,19 +1,30 @@
from flask import render_template, request, redirect, url_for, flash
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import Commande
@webapp.route("/commandes")
@require_page("commandes")
def commandes():
commandes_list = Commande.query.all()
return render_template("commandes.html", commandes=commandes_list)
return render_template("commandes.html", commandes=commandes_list, twitch_permissions=TWITCH_PERMISSIONS)
TWITCH_PERMISSIONS = {'viewer': 'Tous (viewers)', 'sub': 'Abonnés', 'vip': 'VIP', 'moderator': 'Modérateur'}
@webapp.route("/commandes/add", methods=['POST'])
@require_page("commandes")
def add_commande():
if not can_write_page("commandes"):
return render_template("403.html"), 403
trigger = request.form.get('trigger')
response = request.form.get('response')
discord_enable = request.form.get('discord_enable') != None
twitch_enable = request.form.get('twitch_enable') != None
twitch_permission = request.form.get('twitch_permission') or 'viewer'
if twitch_permission not in TWITCH_PERMISSIONS:
twitch_permission = 'viewer'
if trigger and response:
if not trigger.startswith('!'):
@@ -21,28 +32,37 @@ def add_commande():
existing = Commande.query.filter_by(trigger=trigger).first()
if not existing:
commande = Commande(trigger=trigger, response=response, discord_enable=discord_enable, twitch_enable=twitch_enable)
commande = Commande(trigger=trigger, response=response, discord_enable=discord_enable, twitch_enable=twitch_enable, twitch_permission=twitch_permission)
db.session.add(commande)
db.session.commit()
return redirect(url_for('commandes'))
@webapp.route("/commandes/delete/<int:commande_id>")
@require_page("commandes")
def delete_commande(commande_id):
if not can_write_page("commandes"):
return render_template("403.html"), 403
commande = Commande.query.get_or_404(commande_id)
db.session.delete(commande)
db.session.commit()
return redirect(url_for('commandes'))
@webapp.route("/commandes/toggle-discord/<int:commande_id>")
@require_page("commandes")
def toggle_discord_commande(commande_id):
if not can_write_page("commandes"):
return render_template("403.html"), 403
commande = Commande.query.get_or_404(commande_id)
commande.discord_enable = not commande.discord_enable
db.session.commit()
return redirect(url_for('commandes'))
@webapp.route("/commandes/toggle-twitch/<int:commande_id>")
@require_page("commandes")
def toggle_twitch_commande(commande_id):
if not can_write_page("commandes"):
return render_template("403.html"), 403
commande = Commande.query.get_or_404(commande_id)
commande.twitch_enable = not commande.twitch_enable
db.session.commit()
+8 -3
View File
@@ -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'])
@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')
+110
View File
@@ -0,0 +1,110 @@
# Page webapp : configuration des notifications FreeLoot (feed LootScraper)
from flask import render_template, request, redirect, url_for
from urllib.parse import urlencode
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.helpers import ConfigurationHelper
from discordbot import bot
from discordbot.freeloot import send_entry_to_discord_sync
from freeloot_feed import SOURCES, get_display_entries
def _format_updated(updated: str | None) -> str:
"""Formate la date ISO en affichage court."""
if not updated:
return ""
try:
from datetime import datetime
dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
return dt.strftime("%d/%m/%Y %H:%M")
except Exception:
return updated[:16] if len(updated or "") >= 16 else (updated or "")
def _parse_mention_config(raw: str | None) -> tuple[bool, bool, list[str]]:
"""Retourne (everyone, here, list of role_ids) depuis freeloot_mention."""
everyone, here, role_ids = False, False, []
if not raw or not str(raw).strip():
return (everyone, here, role_ids)
for part in str(raw).strip().split(","):
part = part.strip()
if part == "everyone":
everyone = True
elif part == "here":
here = True
elif part.isdigit():
role_ids.append(part)
return (everyone, here, role_ids)
@webapp.route("/freeloot")
@require_page("freeloot")
def openFreeLoot():
helper = ConfigurationHelper()
channels = bot.getAllTextChannel()
roles = bot.getAllRoles()
raw_sources = helper.getValue("freeloot_sources")
enabled_sources = []
if raw_sources and str(raw_sources).strip():
enabled_sources = [s.strip() for s in str(raw_sources).split(",") if s.strip()]
raw_mention = helper.getValue("freeloot_mention")
mention_everyone, mention_here, mention_role_ids = _parse_mention_config(raw_mention)
entries = get_display_entries()
if enabled_sources:
entries = [e for e in entries if e.get("source_key") in enabled_sources]
for e in entries:
e["updated_formatted"] = _format_updated(e.get("updated"))
return render_template(
"freeloot.html",
configuration=helper,
channels=channels,
roles=roles,
sources=SOURCES,
enabled_sources=enabled_sources,
mention_everyone=mention_everyone,
mention_here=mention_here,
mention_role_ids=mention_role_ids,
entries=entries,
)
@webapp.route("/freeloot/update", methods=["POST"])
@require_page("freeloot")
def updateFreeLoot():
if not can_write_page("freeloot"):
return render_template("403.html"), 403
helper = ConfigurationHelper()
enable = request.form.get("freeloot_enable") in ("on", "1", "true", "yes")
channel_id = request.form.get("freeloot_channel_id")
source_keys = request.form.getlist("freeloot_sources")
mention_parts = []
if request.form.get("freeloot_mention_everyone"):
mention_parts.append("everyone")
if request.form.get("freeloot_mention_here"):
mention_parts.append("here")
mention_parts.extend(request.form.getlist("freeloot_mention_roles"))
helper.createOrUpdate("freeloot_enable", "true" if enable else "false")
if channel_id:
try:
helper.createOrUpdate("freeloot_channel_id", str(int(channel_id)))
except ValueError:
pass
helper.createOrUpdate("freeloot_sources", ",".join(source_keys) if source_keys else "")
helper.createOrUpdate("freeloot_mention", ",".join(mention_parts))
db.session.commit()
return redirect(url_for("openFreeLoot") + "?msg=Configuration enregistrée.&type=success")
@webapp.route("/freeloot/send", methods=["POST"])
@require_page("freeloot")
def send_free_loot_to_discord():
if not can_write_page("freeloot"):
return render_template("403.html"), 403
entry_id = (request.form.get("entry_id") or "").strip()
if not entry_id:
return redirect(url_for("openFreeLoot") + "?" + urlencode({"msg": "Entrée manquante.", "type": "error"}))
ok, message = send_entry_to_discord_sync(bot, entry_id)
msg_type = "success" if ok else "error"
return redirect(url_for("openFreeLoot") + "?" + urlencode({"msg": message, "type": msg_type}))
+9 -1
View File
@@ -1,22 +1,30 @@
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import Humeur
@webapp.route("/humeurs")
@require_page("humeurs")
def listHumeurs():
humeurs = Humeur.query.all()
return render_template("humeurs.html", humeurs = humeurs)
return render_template("humeurs.html", humeurs=humeurs)
@webapp.route('/humeurs/add', methods=['POST'])
@require_page("humeurs")
def addHumeur():
if not can_write_page("humeurs"):
return render_template("403.html"), 403
humeur = Humeur(text=request.form['text'])
db.session.add(humeur)
db.session.commit()
return redirect(url_for('listHumeurs'))
@webapp.route('/humeurs/del/<id>')
@require_page("humeurs")
def delHumeur(id):
if not can_write_page("humeurs"):
return render_template("403.html"), 403
Humeur.query.filter_by(id=id).delete()
db.session.commit()
return redirect(url_for('listHumeurs'))
+17 -1
View File
@@ -1,6 +1,22 @@
from flask import render_template
from webapp import webapp
from webapp.auth import require_page
from database.models import ModerationEvent, TwitchAnnouncement, TwitchModerationLog
@webapp.route("/")
@require_page("index")
def index():
return render_template("index.html")
status = webapp.config["BOT_STATUS"]
sanctions_count = ModerationEvent.query.count()
twitch_announcements_count = TwitchAnnouncement.query.count()
twitch_moderation_count = TwitchModerationLog.query.count()
return render_template(
"index.html",
discord_connected=status["discord_connected"],
discord_guild_count=status["discord_guild_count"],
sanctions_count=sanctions_count,
twitch_connected=status["twitch_connected"],
twitch_channel_name=status["twitch_channel_name"],
twitch_announcements_count=twitch_announcements_count,
twitch_moderation_count=twitch_moderation_count,
)
+103
View File
@@ -0,0 +1,103 @@
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import TwitchLinkFilter, TwitchAllowedDomain, TwitchAllowedUser
def _get_or_create_config():
config = TwitchLinkFilter.query.first()
if not config:
config = TwitchLinkFilter(enabled=False)
db.session.add(config)
db.session.commit()
return config
@webapp.route("/link-filter")
@require_page("link_filter")
def link_filter():
config = _get_or_create_config()
domains = TwitchAllowedDomain.query.order_by(TwitchAllowedDomain.domain).all()
users = TwitchAllowedUser.query.order_by(TwitchAllowedUser.username).all()
return render_template("link-filter.html", config=config, domains=domains, users=users)
@webapp.route("/link-filter/toggle")
@require_page("link_filter")
def toggle_link_filter():
if not can_write_page("link_filter"):
return render_template("403.html"), 403
config = _get_or_create_config()
config.enabled = not config.enabled
db.session.commit()
return redirect(url_for('link_filter'))
@webapp.route("/link-filter/update", methods=['POST'])
@require_page("link_filter")
def update_link_filter():
if not can_write_page("link_filter"):
return render_template("403.html"), 403
config = _get_or_create_config()
config.allow_subscribers = request.form.get('allow_subscribers') is not None
config.allow_vips = request.form.get('allow_vips') is not None
config.allow_moderators = request.form.get('allow_moderators') is not None
config.timeout_duration = int(request.form.get('timeout_duration', 60))
config.warning_message = request.form.get('warning_message', '')
db.session.commit()
return redirect(url_for('link_filter'))
@webapp.route("/link-filter/domain/add", methods=['POST'])
@require_page("link_filter")
def add_allowed_domain():
if not can_write_page("link_filter"):
return render_template("403.html"), 403
domain = request.form.get('domain', '').strip().lower()
if domain:
domain = domain.replace('https://', '').replace('http://', '').replace('www.', '')
domain = domain.split('/')[0]
existing = TwitchAllowedDomain.query.filter_by(domain=domain).first()
if not existing:
new_domain = TwitchAllowedDomain(domain=domain)
db.session.add(new_domain)
db.session.commit()
return redirect(url_for('link_filter'))
@webapp.route("/link-filter/domain/delete/<int:domain_id>")
@require_page("link_filter")
def delete_allowed_domain(domain_id):
if not can_write_page("link_filter"):
return render_template("403.html"), 403
domain = TwitchAllowedDomain.query.get_or_404(domain_id)
db.session.delete(domain)
db.session.commit()
return redirect(url_for('link_filter'))
@webapp.route("/link-filter/user/add", methods=['POST'])
@require_page("link_filter")
def add_allowed_user():
if not can_write_page("link_filter"):
return render_template("403.html"), 403
username = request.form.get('username', '').strip().lower().lstrip('@')
if username:
existing = TwitchAllowedUser.query.filter_by(username=username).first()
if not existing:
new_user = TwitchAllowedUser(username=username)
db.session.add(new_user)
db.session.commit()
return redirect(url_for('link_filter'))
@webapp.route("/link-filter/user/delete/<int:user_id>")
@require_page("link_filter")
def delete_allowed_user(user_id):
if not can_write_page("link_filter"):
return render_template("403.html"), 403
user = TwitchAllowedUser.query.get_or_404(user_id)
db.session.delete(user)
db.session.commit()
return redirect(url_for('link_filter'))
+57 -3
View File
@@ -1,12 +1,14 @@
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import LiveAlert
from discordbot import bot
@webapp.route("/live-alert")
@require_page("live_alert")
def openLiveAlert():
alerts : list[LiveAlert] = LiveAlert.query.all()
channels = bot.getAllTextChannel()
@@ -17,37 +19,89 @@ def openLiveAlert():
return render_template("live-alert.html", alerts = alerts, channels = channels)
@webapp.route("/live-alert/add", methods=['POST'])
@require_page("live_alert")
def addLiveAlert():
alert = LiveAlert(enable = True, login = request.form.get('login'), notify_channel = request.form.get('notify_channel'), message = request.form.get('message'))
if not can_write_page("live_alert"):
return render_template("403.html"), 403
embed_color = (request.form.get('embed_color') or '9146FF').strip().lstrip('#')
if len(embed_color) != 6:
embed_color = '9146FF'
alert = LiveAlert(
enable=True,
login=request.form.get('login'),
notify_channel=request.form.get('notify_channel'),
message=(request.form.get('message') or '').strip(),
watch_activity=request.form.get('watch_activity') == '1',
embed_title=request.form.get('embed_title') or None,
embed_description=request.form.get('embed_description') or None,
embed_color=embed_color,
embed_footer=request.form.get('embed_footer') or None,
embed_author_name=request.form.get('embed_author_name') or None,
embed_author_icon=request.form.get('embed_author_icon') or None,
embed_thumbnail=request.form.get('embed_thumbnail') == 'on',
embed_image=request.form.get('embed_image') == 'on',
)
db.session.add(alert)
db.session.commit()
return redirect(url_for("openLiveAlert"))
@webapp.route("/live-alert/toggle/<int:id>")
@require_page("live_alert")
def toggleLiveAlert(id):
if not can_write_page("live_alert"):
return render_template("403.html"), 403
alert : LiveAlert = LiveAlert.query.get_or_404(id)
alert.enable = not alert.enable
db.session.commit()
return redirect(url_for("openLiveAlert"))
@webapp.route("/live-alert/edit/<int:id>")
@require_page("live_alert")
def openEditLiveAlert(id):
alert = LiveAlert.query.get_or_404(id)
channels = bot.getAllTextChannel()
return render_template("live-alert.html", alert = alert, channels = channels)
@webapp.route("/live-alert/edit/<int:id>", methods=['POST'])
@require_page("live_alert")
def submitEditLiveAlert(id):
alert : LiveAlert = LiveAlert.query.get_or_404(id)
if not can_write_page("live_alert"):
return render_template("403.html"), 403
alert: LiveAlert = LiveAlert.query.get_or_404(id)
embed_color = (request.form.get('embed_color') or '9146FF').strip().lstrip('#')
if len(embed_color) != 6:
embed_color = '9146FF'
alert.login = request.form.get('login')
alert.notify_channel = request.form.get('notify_channel')
alert.message = request.form.get('message')
alert.message = (request.form.get('message') or '').strip()
alert.watch_activity = request.form.get('watch_activity') == '1'
alert.embed_title = request.form.get('embed_title') or None
alert.embed_description = request.form.get('embed_description') or None
alert.embed_color = embed_color
alert.embed_footer = request.form.get('embed_footer') or None
alert.embed_author_name = request.form.get('embed_author_name') or None
alert.embed_author_icon = request.form.get('embed_author_icon') or None
alert.embed_thumbnail = request.form.get('embed_thumbnail') == 'on'
alert.embed_image = request.form.get('embed_image') == 'on'
db.session.commit()
return redirect(url_for("openLiveAlert"))
@webapp.route("/live-alert/toggle-watch/<int:id>")
@require_page("live_alert")
def toggleWatchActivity(id):
if not can_write_page("live_alert"):
return render_template("403.html"), 403
alert : LiveAlert = LiveAlert.query.get_or_404(id)
alert.watch_activity = not alert.watch_activity
db.session.commit()
return redirect(url_for("openLiveAlert"))
@webapp.route("/live-alert/del/<int:id>")
@require_page("live_alert")
def delLiveAlert(id):
if not can_write_page("live_alert"):
return render_template("403.html"), 403
alert = LiveAlert.query.get_or_404(id)
db.session.delete(alert)
db.session.commit()
+53 -2
View File
@@ -1,28 +1,79 @@
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
def _top_sanctioned():
return (
db.session.query(
ModerationEvent.discord_id,
db.func.max(ModerationEvent.username).label("username"),
db.func.count(ModerationEvent.id).label("count"),
)
.group_by(ModerationEvent.discord_id)
.order_by(db.func.count(ModerationEvent.id).desc())
.limit(3)
.all()
)
def _top_moderators():
return (
db.session.query(
ModerationEvent.staff_id,
db.func.max(ModerationEvent.staff_name).label("staff_name"),
db.func.count(ModerationEvent.id).label("count"),
)
.group_by(ModerationEvent.staff_id)
.order_by(db.func.count(ModerationEvent.id).desc())
.limit(3)
.all()
)
@webapp.route("/moderation")
@require_page("moderation")
def moderation():
events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
return render_template("moderation.html", events=events, event=None)
top_sanctioned = _top_sanctioned()
top_moderators = _top_moderators()
return render_template(
"moderation.html",
events=events,
event=None,
top_sanctioned=top_sanctioned,
top_moderators=top_moderators,
)
@webapp.route("/moderation/edit/<int:event_id>")
@require_page("moderation")
def open_edit_moderation_event(event_id):
event = ModerationEvent.query.get_or_404(event_id)
events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
return render_template("moderation.html", events=events, event=event)
top_sanctioned = _top_sanctioned()
top_moderators = _top_moderators()
return render_template(
"moderation.html",
events=events,
event=event,
top_sanctioned=top_sanctioned,
top_moderators=top_moderators,
)
@webapp.route("/moderation/update/<int:event_id>", methods=['POST'])
@require_page("moderation")
def update_moderation_event(event_id):
if not can_write_page("moderation"):
return render_template("403.html"), 403
event = ModerationEvent.query.get_or_404(event_id)
event.reason = request.form.get('reason')
db.session.commit()
return redirect(url_for('moderation'))
@webapp.route("/moderation/delete/<int:event_id>")
@require_page("moderation")
def delete_moderation_event(event_id):
if not can_write_page("moderation"):
return render_template("403.html"), 403
event = ModerationEvent.query.get_or_404(event_id)
db.session.delete(event)
db.session.commit()
+11 -3
View File
@@ -1,23 +1,31 @@
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import GameAlias
from database.helpers import ConfigurationHelper
@webapp.route("/protondb")
@require_page("protondb")
def openProtonDB():
aliases = GameAlias.query.all()
return render_template("protondb.html", aliases = aliases, configuration = ConfigurationHelper())
return render_template("protondb.html", aliases=aliases, configuration=ConfigurationHelper())
@webapp.route("/protondb/gamealias/add", methods=['POST'])
@require_page("protondb")
def addGameAlias():
game_alias = GameAlias(alias = request.form.get('alias'), name = request.form.get('name'))
if not can_write_page("protondb"):
return render_template("403.html"), 403
game_alias = GameAlias(alias=request.form.get('alias'), name=request.form.get('name'))
db.session.add(game_alias)
db.session.commit()
return redirect(url_for('openProtonDB'))
@webapp.route('/protondb/gamealias/del/<int:id>')
def delGameAlias(id : int):
@require_page("protondb")
def delGameAlias(id: int):
if not can_write_page("protondb"):
return render_template("403.html"), 403
GameAlias.query.filter_by(id=id).delete()
db.session.commit()
return redirect(url_for('openProtonDB'))
+329
View File
@@ -0,0 +1,329 @@
# Paramètres webapp : rôles, permissions par page, inscriptions (super administrateur uniquement).
from flask import render_template, request, redirect, url_for, flash
from webapp import webapp
from webapp.auth import require_page
from database import db
from database.models import WebappRole, PagePermission, WebappUser
from database.helpers import ConfigurationHelper
# Métadonnées des pages : catégorie, label d'affichage, description
PAGE_METADATA = {
"index": {
"label": "Tableau de bord",
"category": "general",
"description": "Page d'accueil avec aperçu du système",
"icon": "home"
},
"commandes": {
"label": "Commandes",
"category": "content",
"description": "Gérer les commandes Discord et Twitch",
"icon": "terminal"
},
"configurations": {
"label": "Configurations",
"category": "config",
"description": "Paramètres généraux du bot",
"icon": "settings"
},
"humeurs": {
"label": "Humeurs",
"category": "content",
"description": "Gérer les statuts du bot Discord",
"icon": "smile"
},
"protondb": {
"label": "ProtonDB",
"category": "content",
"description": "Recherche de compatibilité des jeux Linux",
"icon": "gamepad"
},
"live_alert": {
"label": "Alertes Live",
"category": "content",
"description": "Notifications Discord pour les streams Twitch",
"icon": "bell"
},
"youtube": {
"label": "YouTube",
"category": "content",
"description": "Notifications Discord pour les vidéos YouTube",
"icon": "video"
},
"announcements": {
"label": "Annonces Twitch",
"category": "content",
"description": "Messages automatiques dans le chat Twitch",
"icon": "megaphone"
},
"moderation": {
"label": "Modération Discord",
"category": "moderation",
"description": "Historique de modération Discord",
"icon": "shield"
},
"twitch_moderation": {
"label": "Modération Twitch",
"category": "moderation",
"description": "Commandes et logs de modération Twitch",
"icon": "shield-check"
},
"link_filter": {
"label": "Filtre de liens",
"category": "moderation",
"description": "Filtrage automatique des liens Twitch",
"icon": "filter"
},
"twitch_events": {
"label": "Événements Twitch",
"category": "content",
"description": "Notifications subs, follows, raids, clips",
"icon": "star"
},
"freeloot": {
"label": "Free Loot",
"category": "content",
"description": "Flux RSS de jeux gratuits vers Discord",
"icon": "gift"
},
"users": {
"label": "Utilisateurs",
"category": "admin",
"description": "Gestion des comptes et rôles webapp",
"icon": "users"
},
"settings": {
"label": "Paramètres",
"category": "admin",
"description": "Rôles, permissions et inscriptions",
"icon": "cog"
},
}
# Labels des catégories
CATEGORY_LABELS = {
"general": {"label": "Général", "color": "#6B7280", "icon": "layout"},
"content": {"label": "Contenu", "color": "#3B82F6", "icon": "file-text"},
"moderation": {"label": "Modération", "color": "#EF4444", "icon": "shield"},
"config": {"label": "Configuration", "color": "#8B5CF6", "icon": "settings"},
"admin": {"label": "Administration", "color": "#F59E0B", "icon": "crown"},
}
# Rôles par défaut avec métadonnées
DEFAULT_ROLES = {
"viewer_twitch": {
"description": "Accès minimal, consultation uniquement",
"color": "#9146FF",
"icon": "eye"
},
"utilisateur_discord": {
"description": "Peut consulter et modifier du contenu basique",
"color": "#5865F2",
"icon": "user"
},
"moderateur_discord": {
"description": "Accès aux outils de modération Discord",
"color": "#57F287",
"icon": "shield"
},
"expert_discord": {
"description": "Gestion avancée du contenu et des configurations",
"color": "#FEE75C",
"icon": "star"
},
"moderateur_twitch": {
"description": "Accès aux outils de modération Twitch",
"color": "#9146FF",
"icon": "shield-check"
},
"super_administrateur": {
"description": "Accès complet à toutes les fonctionnalités",
"color": "#ED4245",
"icon": "crown"
},
}
PAGE_KEYS = [
("index", "Tableau de bord"),
("configurations", "Configurations"),
("commandes", "Commandes"),
("humeurs", "Humeurs"),
("live_alert", "Alerte live"),
("announcements", "Annonces Twitch"),
("twitch_moderation", "Modération Twitch"),
("link_filter", "Filtre de liens"),
("twitch_events", "Notifications événements Twitch"),
("youtube", "YouTube"),
("protondb", "ProtonDB"),
("freeloot", "FreeLoot"),
("moderation", "Modération Discord"),
("users", "Utilisateurs"),
("settings", "Paramètres"),
]
@webapp.route("/settings")
@require_page("settings")
def settings():
roles = WebappRole.query.order_by(WebappRole.level).all()
permissions = PagePermission.query.all()
perm_by_key = {p.page_key: p for p in permissions}
reg_enabled = ConfigurationHelper().getValue("registration_enabled") not in (None, "", "false", "0", "no", "off")
# Organiser les pages par catégorie
pages_by_category = {}
for page_key, meta in PAGE_METADATA.items():
category = meta.get("category", "general")
if category not in pages_by_category:
pages_by_category[category] = []
pages_by_category[category].append({
"key": page_key,
"meta": meta,
"permission": perm_by_key.get(page_key)
})
# Trier les pages dans chaque catégorie par label
for category in pages_by_category:
pages_by_category[category].sort(key=lambda x: x["meta"]["label"])
return render_template(
"settings.html",
roles=roles,
pages_by_category=pages_by_category,
category_labels=CATEGORY_LABELS,
perm_by_key=perm_by_key,
registration_enabled=reg_enabled,
page_metadata=PAGE_METADATA,
default_roles_meta=DEFAULT_ROLES,
)
@webapp.route("/settings/registration", methods=["POST"])
@require_page("settings")
def settings_toggle_registration():
enabled = request.form.get("enabled") in ("1", "true", "on", "yes")
ConfigurationHelper().createOrUpdate("registration_enabled", "true" if enabled else "false")
db.session.commit()
flash("Inscriptions " + ("activées" if enabled else "désactivées") + ".", "success")
return redirect(url_for("settings"))
@webapp.route("/settings/roles/add", methods=["POST"])
@require_page("settings")
def settings_role_add():
name = (request.form.get("name") or "").strip()
level_str = request.form.get("level", "0")
description = (request.form.get("description") or "").strip()
color = (request.form.get("color") or "#6B7280").strip()
icon = (request.form.get("icon") or "").strip()
if not name:
flash("Nom du rôle requis.", "error")
return redirect(url_for("settings"))
try:
level = int(level_str)
except ValueError:
level = 0
if WebappRole.query.filter_by(name=name).first():
flash(f"Le rôle « {name} » existe déjà.", "error")
return redirect(url_for("settings"))
role = WebappRole(
name=name,
level=level,
description=description if description else None,
color=color,
icon=icon if icon else None
)
db.session.add(role)
db.session.commit()
flash(f"Rôle « {name} » créé.", "success")
return redirect(url_for("settings"))
@webapp.route("/settings/roles/<int:role_id>/edit", methods=["POST"])
@require_page("settings")
def settings_role_edit(role_id):
role = WebappRole.query.get_or_404(role_id)
level_str = request.form.get("level")
description = request.form.get("description")
color = request.form.get("color")
icon = request.form.get("icon")
if level_str is not None:
try:
role.level = int(level_str)
except ValueError:
flash("Niveau invalide.", "error")
return redirect(url_for("settings"))
if description is not None:
role.description = description.strip() if description.strip() else None
if color is not None:
role.color = color.strip() if color.strip() else "#6B7280"
if icon is not None:
role.icon = icon.strip() if icon.strip() else None
db.session.commit()
flash(f"Rôle « {role.name} » mis à jour.", "success")
return redirect(url_for("settings"))
@webapp.route("/settings/roles/<int:role_id>/delete", methods=["POST"])
@require_page("settings")
def settings_role_delete(role_id):
role = WebappRole.query.get_or_404(role_id)
if WebappUser.query.filter_by(role=role.name).count() > 0:
flash(f"Impossible de supprimer le rôle « {role.name} » : des utilisateurs l'utilisent.", "error")
return redirect(url_for("settings"))
db.session.delete(role)
db.session.commit()
flash(f"Rôle « {role.name} » supprimé.", "success")
return redirect(url_for("settings"))
@webapp.route("/settings/permissions/update", methods=["POST"])
@require_page("settings")
def settings_permissions_update():
page_key = request.form.get("page_key")
role_name = request.form.get("role")
if not page_key:
return redirect(url_for("settings"))
role = WebappRole.query.filter_by(name=role_name).first()
level = role.level if role else 0
perm = PagePermission.query.filter_by(page_key=page_key).first()
if perm:
perm.min_level = level
perm.write_level = level
else:
perm = PagePermission(page_key=page_key, min_level=level, write_level=level)
db.session.add(perm)
db.session.commit()
flash(f"Accès à « {page_key} » mis à jour.", "success")
return redirect(url_for("settings"))
@webapp.route("/settings/permissions/bulk", methods=["POST"])
@require_page("settings")
def settings_permissions_bulk():
page_keys = request.form.getlist("page_keys")
role_name = request.form.get("role")
if not page_keys or not role_name:
flash("Sélectionnez au moins une page et un rôle.", "error")
return redirect(url_for("settings"))
role = WebappRole.query.filter_by(name=role_name).first()
level = role.level if role else 0
updated = 0
for page_key in page_keys:
perm = PagePermission.query.filter_by(page_key=page_key).first()
if perm:
perm.min_level = level
perm.write_level = level
else:
perm = PagePermission(page_key=page_key, min_level=level, write_level=level)
db.session.add(perm)
updated += 1
db.session.commit()
flash(f"Accès mis à jour pour {updated} page(s) avec le rôle « {role_name} ».", "success")
return redirect(url_for("settings"))
+9
View File
@@ -0,0 +1,9 @@
{% extends "template.html" %}
{% block content %}
<div class="max-w-md mx-auto py-12 text-center">
<h1 class="text-2xl font-bold text-slate-800 dark:text-white mb-2">Accès refusé</h1>
<p class="text-slate-600 dark:text-slate-400 mb-6">Vous n'avez pas les droits nécessaires pour accéder à cette page.</p>
<a href="{{ url_for('index') }}" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">Retour à l'accueil</a>
</div>
{% endblock %}
+188
View File
@@ -0,0 +1,188 @@
{% extends "template.html" %}
{% block content %}
<div class="space-y-8">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path>
</svg>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Annonces Twitch</h1>
<p class="text-gray-600 dark:text-gray-400">Messages automatiques périodiques dans le chat</p>
</div>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Configurez des messages envoyés automatiquement dans le chat Twitch.
L'annonce est envoyée uniquement si le temps est écoulé ET si le nombre minimum de messages a été atteint.
</p>
</div>
{% if not announcement %}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Annonces configurées</h2>
</div>
{% if announcements %}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Nom</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Temps</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Min. messages</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Dernier envoi</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for ann in announcements %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<td class="px-6 py-4">
<span class="font-medium text-gray-900 dark:text-white">{{ ann.name }}</span>
</td>
<td class="px-6 py-4">
<span class="text-gray-600 dark:text-gray-300 text-sm max-w-xs truncate block">{{ ann.text[:60] }}{% if ann.text|length > 60 %}...{% endif %}</span>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
{{ ann.periodicity }} min
</span>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
{{ ann.min_chat_messages }}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{% if ann.last_sent %}
{{ ann.last_sent.strftime('%d/%m %H:%M') }}
{% else %}
<span class="italic">Jamais</span>
{% endif %}
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('toggleAnnouncement', id=ann.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="{{ 'Désactiver' if ann.enable else 'Activer' }}">
{% if ann.enable %}
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{% else %}
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{% endif %}
</a>
<a href="{{ url_for('resetAnnouncement', id=ann.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Remettre le compteur à zéro">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</a>
<a href="{{ url_for('openEditAnnouncement', id=ann.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Modifier">
<svg class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</a>
<a href="{{ url_for('delAnnouncement', id=ann.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette annonce ?')"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
title="Supprimer">
<svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="px-6 py-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">Aucune annonce</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Commencez par créer votre première annonce automatique.</p>
</div>
{% endif %}
</div>
{% endif %}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-6">
{{ 'Modifier l\'annonce' if announcement else 'Ajouter une annonce' }}
</h2>
<form action="{{ url_for('submitEditAnnouncement', id=announcement.id) if announcement else url_for('addAnnouncement') }}" method="POST" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Nom de l'annonce
</label>
<input type="text" name="name" id="name" required maxlength="64"
value="{{ announcement.name if announcement else '' }}"
placeholder="Ex: Règles du chat"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
</div>
<div>
<label for="periodicity" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Temps entre les annonces (minutes)
</label>
<input type="number" name="periodicity" id="periodicity" required min="1" max="1440"
value="{{ announcement.periodicity if announcement else 10 }}"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">1 min à 1440 min (24h)</p>
</div>
<div>
<label for="min_chat_messages" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Messages minimum entre annonces
</label>
<input type="number" name="min_chat_messages" id="min_chat_messages" required min="0" max="1000"
value="{{ announcement.min_chat_messages if announcement else 0 }}"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">0 = pas de minimum</p>
</div>
</div>
<div>
<label for="text" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Message
</label>
<textarea name="text" id="text" required maxlength="500" rows="4"
placeholder="Le message qui sera envoyé dans le chat..."
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors resize-none">{{ announcement.text if announcement else '' }}</textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Maximum 500 caractères</p>
</div>
<div class="flex items-center gap-4">
<button type="submit"
class="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2">
{{ 'Enregistrer' if announcement else 'Ajouter' }}
</button>
{% if announcement %}
<a href="{{ url_for('openAnnouncements') }}"
class="px-6 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-lg transition-colors">
Annuler
</a>
{% endif %}
</div>
</form>
</div>
</div>
{% endblock %}
+106 -77
View File
@@ -1,99 +1,128 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Commandes</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Gérez les commandes personnalisées du bot. Ces commandes peuvent être activées sur Discord et/ou Twitch.
</p>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Commande</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Réponse</th>
<th class="px-4 py-3 text-center text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Discord</th>
<th class="px-4 py-3 text-center text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Twitch</th>
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
{% for commande in commandes %}
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
<td class="px-4 py-3">
<code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded text-xs font-mono">{{ commande.trigger }}</code>
</td>
<td class="px-4 py-3 text-slate-600 dark:text-slate-400 text-sm max-w-xs">
<div class="line-clamp-2">{{ commande.response }}</div>
</td>
<td class="px-4 py-3 text-center">
<a href="{{ url_for('toggle_discord_commande', commande_id = commande.id) }}" class="inline-flex" title="{{ 'Désactiver' if commande.discord_enable else 'Activer' }}">
{% if commande.discord_enable %}
<span class="w-5 h-5 text-green-600 dark:text-green-500"></span>
{% else %}
<span class="w-5 h-5 text-slate-400"></span>
{% endif %}
</a>
</td>
<td class="px-4 py-3 text-center">
<a href="{{ url_for('toggle_twitch_commande', commande_id = commande.id) }}" class="inline-flex" title="{{ 'Désactiver' if commande.twitch_enable else 'Activer' }}">
{% if commande.twitch_enable %}
<span class="w-5 h-5 text-green-600 dark:text-green-500"></span>
{% else %}
<span class="w-5 h-5 text-slate-400"></span>
{% endif %}
</a>
</td>
<td class="px-4 py-3 text-right">
<a href="{{ url_for('delete_commande', commande_id = commande.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette commande ?')" class="text-sm text-slate-500 hover:text-red-600 dark:hover:text-red-400 transition-colors">
Supprimer
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="px-4 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
Aucune commande configurée
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div 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>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-5">
<h2 class="text-lg font-medium text-slate-800 dark:text-white mb-5">Ajouter une commande</h2>
<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>
<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-slate-700 dark:text-slate-300 mb-2">Commande</label>
<input type="text" name="trigger" id="trigger" placeholder="!macommande" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
<label for="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 items-end gap-6">
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="discord_enable" checked class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500 focus:ring-2">
<span class="text-sm text-slate-700 dark:text-slate-300">Discord</span>
<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-3 cursor-pointer">
<input type="checkbox" name="twitch_enable" class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500 focus:ring-2">
<span class="text-sm text-slate-700 dark:text-slate-300">Twitch</span>
<label 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-slate-700 dark:text-slate-300 mb-2">Réponse</label>
<textarea name="response" id="response" rows="4" placeholder="Le message que le bot enverra..." class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all resize-none"></textarea>
<label for="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 class="flex justify-end">
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
Ajouter
<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>
+300 -210
View File
@@ -1,251 +1,341 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Configurations</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Paramètres Discord, Twitch et Humble Bundle.
</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>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 mb-6 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Discord</h2>
</div>
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="p-5 space-y-6">
<div>
<label for="discord_token" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Token Discord</label>
<input type="password" name="discord_token" id="discord_token" placeholder="Votre token Discord (caché)" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">Nécessite un redémarrage après modification</p>
<div class="space-y-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center gap-3 mb-6">
<svg class="w-6 h-6 text-indigo-500" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Discord</h2>
</div>
<div class="pt-4 border-t border-slate-200 dark:border-slate-700">
<h3 class="text-sm font-medium text-slate-800 dark:text-white mb-4">Messages de bienvenue</h3>
<label class="flex items-center gap-3 cursor-pointer mb-4">
<input type="checkbox" name="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
<span class="text-sm text-slate-700 dark:text-slate-300">Activer le message de bienvenue</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<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="welcome_channel_id" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Canal de bienvenue</label>
<select name="welcome_channel_id" id="welcome_channel_id" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('welcome_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
<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>
<label for="welcome_message" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Message personnalisé</label>
<textarea name="welcome_message" id="welcome_message" rows="2" placeholder="Bienvenue {member.mention} sur le serveur !" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all resize-none">{{ configuration.getValue('welcome_message') }}</textarea>
</div>
<div class="mt-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg p-3">
<p class="text-xs font-medium text-slate-600 dark:text-slate-400 mb-2">Variables :</p>
<div class="flex flex-wrap gap-2 text-xs">
<code class="px-1.5 py-0.5 bg-slate-200 dark:bg-slate-600 rounded">{member.mention}</code>
<code class="px-1.5 py-0.5 bg-slate-200 dark:bg-slate-600 rounded">{member.name}</code>
<code class="px-1.5 py-0.5 bg-slate-200 dark:bg-slate-600 rounded">{server.name}</code>
<code class="px-1.5 py-0.5 bg-slate-200 dark:bg-slate-600 rounded">{server.member_count}</code>
</div>
</div>
</div>
<div class="pt-4 border-t border-slate-200 dark:border-slate-700">
<h3 class="text-sm font-medium text-slate-800 dark:text-white mb-4">Messages de départ</h3>
<label class="flex items-center gap-3 cursor-pointer mb-4">
<input type="checkbox" name="leave_enable" {% if configuration.getValue('leave_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
<span class="text-sm text-slate-700 dark:text-slate-300">Activer le message de départ</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="leave_channel_id" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Canal de départ</label>
<select name="leave_channel_id" id="leave_channel_id" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('leave_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label for="leave_message" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Message personnalisé</label>
<textarea name="leave_message" id="leave_message" rows="2" placeholder="{member.mention} a quitté le serveur." class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all resize-none">{{ configuration.getValue('leave_message') }}</textarea>
</div>
</div>
<div class="pt-4 border-t border-slate-200 dark:border-slate-700">
<h3 class="text-sm font-medium text-slate-800 dark:text-white mb-4">Modération</h3>
<div class="space-y-3 mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
<span class="text-sm text-slate-700 dark:text-slate-300">Activer les commandes d'avertissement</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
<span class="text-sm text-slate-700 dark:text-slate-300">Activer les commandes de bannissement</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
<span class="text-sm text-slate-700 dark:text-slate-300">Activer la commande d'expulsion</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="moderation_log_channel_id" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Canal de logs</label>
<select name="moderation_log_channel_id" id="moderation_log_channel_id" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('moderation_log_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="moderation_embed_delete_delay" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Délai suppression (sec)</label>
<input type="number" name="moderation_embed_delete_delay" id="moderation_embed_delete_delay" value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" min="0" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">Rôles Staff autorisés</label>
{% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %}
<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>
{% if roles|length > 1 %}
<div class="flex flex-wrap gap-2 mb-3">
{% for guild_data in roles %}
<button type="button" onclick="openTab(event, 'guild-{{ guild_data.guild_id }}')" class="tab-button px-3 py-1.5 text-sm rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors {% if loop.first %}active bg-slate-200 dark:bg-slate-600{% endif %}">
{{ guild_data.guild_name }}
</button>
{% endfor %}
</div>
{% endif %}
<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>
{% for guild_data in roles %}
<div id="guild-{{ guild_data.guild_id }}" class="tab-content {% if not loop.first %}hidden{% endif %}">
<div class="max-h-48 overflow-y-auto bg-slate-50 dark:bg-slate-700/50 rounded-lg p-3 space-y-1">
{% for role in guild_data.roles %}
<label class="flex items-center gap-3 cursor-pointer p-2 rounded hover:bg-slate-100 dark:hover:bg-slate-600/50 transition-colors">
<input type="checkbox" name="moderation_staff_role_ids" value="{{ role.id }}" {% if role.id|string in selected_roles %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
{% if role.color.value != 0 %}
<span style="color:#{{ '%06x' % role.color.value }}"></span>
{% else %}
<span class="text-slate-400"></span>
{% endif %}
<span class="text-sm text-slate-700 dark:text-slate-300">{{ role.name }}</span>
</label>
<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>
{% endfor %}
</div>
</div>
<div class="pt-4 border-t border-slate-200 dark:border-slate-700">
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
Enregistrer
<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>
</div>
</form>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 mb-6 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">API Twitch</h2>
</form>
</div>
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="p-5 space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="twitch_client_id" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Client ID</label>
<input type="text" name="twitch_client_id" id="twitch_client_id" value="{{ configuration.getValue('twitch_client_id') }}" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
<div>
<label for="twitch_client_secret" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Client Secret</label>
<input type="text" name="twitch_client_secret" id="twitch_client_secret" value="{{ configuration.getValue('twitch_client_secret') }}" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center gap-3 mb-6">
<svg class="w-6 h-6 text-purple-500" fill="currentColor" viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57l-.002-5.143zm3.43 0H16.714v5.143H15V4.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0H6zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714v9.429z"/></svg>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">API Twitch</h2>
</div>
<div>
<label for="twitch_channel" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Chaîne à rejoindre</label>
<input type="text" name="twitch_channel" id="twitch_channel" value="{{ configuration.getValue('twitch_channel') }}" placeholder="#machinTruc" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="twitch_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client ID</label>
<input name="twitch_client_id" id="twitch_client_id" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('twitch_client_id') }}"/>
</div>
<div>
<label for="twitch_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Secret</label>
<input name="twitch_client_secret" id="twitch_client_secret" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('twitch_client_secret') }}"/>
</div>
</div>
<div>
<label for="twitch_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Chaîne à rejoindre</label>
<input name="twitch_channel" id="twitch_channel" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="#machinTruc"
value="{{ configuration.getValue('twitch_channel') }}"/>
</div>
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
<div>
<a href="{{ url_for('twitchRequestToken') }}" class="text-sm text-slate-600 dark:text-slate-400 hover:underline">
Obtenir token et refresh token
</a>
<div 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 class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="twitch_commands_enable" {% if configuration.getValue('twitch_commands_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les commandes personnalisées (!commande)</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 ml-7">Les commandes configurées dans la page "Commandes" seront actives dans le chat Twitch</p>
</div>
<div class="flex items-center gap-4">
<button type="submit"
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer la configuration Twitch
</button>
<a href="{{ url_for('twitchConfigurationHelp') }}" class="text-purple-600 dark:text-purple-400 hover:underline text-sm">Aide</a>
</div>
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<a href="{{ url_for('twitchRequestToken') }}"
class="inline-flex items-center gap-2 px-4 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded-lg hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors text-sm font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path></svg>
Obtenir token et refresh token
</a>
<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>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center gap-3 mb-6">
<svg class="w-6 h-6 text-orange-500" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Humble Bundle</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="twitch_access_token" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Access Token</label>
<input type="text" name="twitch_access_token" id="twitch_access_token" value="{{ configuration.getValue('twitch_access_token') }}" readonly class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 font-mono">
<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>
<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="twitch_refresh_token" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Refresh Token</label>
<input type="text" name="twitch_refresh_token" id="twitch_refresh_token" value="{{ configuration.getValue('twitch_refresh_token') }}" readonly class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 font-mono">
<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>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400">Nécessite un redémarrage après l'obtention des tokens</p>
{% endif %}
<div class="flex items-center gap-4">
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
Enregistrer
<button 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>
<a href="{{ url_for('twitchConfigurationHelp') }}" class="text-sm text-slate-600 dark:text-slate-400 hover:underline">Besoin d'aide ?</a>
</div>
</form>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Humble Bundle</h2>
</form>
</div>
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="p-5 space-y-6">
<p class="text-sm text-slate-600 dark:text-slate-400">
Activez les notifications pour recevoir automatiquement les nouveaux packs sur Discord.
</p>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
<span class="text-sm text-slate-700 dark:text-slate-300">Activer les notifications Humble Bundle</span>
</label>
<div>
<label for="humble_bundle_channel" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Canal de notification</label>
<select name="humble_bundle_channel" id="humble_bundle_channel" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('humble_bundle_channel') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
Enregistrer
</button>
</form>
</div>
<script>
function openTab(evt, tabName) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.tab-button').forEach(el => {
el.classList.remove('active', 'bg-slate-200', 'dark:bg-slate-600');
el.classList.add('bg-slate-100', 'dark:bg-slate-700');
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.add('active', 'bg-slate-200', 'dark:bg-slate-600');
evt.currentTarget.classList.remove('bg-slate-100', 'dark:bg-slate-700');
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 %}
+224
View File
@@ -0,0 +1,224 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">FreeLoot — Jeux gratuits</h1>
{% if request.args.get('msg') %}
{% set msg_type = request.args.get('type') %}
<div class="mb-4 p-4 rounded-lg {% if msg_type == 'success' %}bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300{% elif msg_type == 'error' %}bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300{% else %}bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300{% endif %}">
{{ request.args.get('msg') }}
</div>
{% endif %}
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Notifications des jeux gratuits (Epic Games, Amazon Prime, GOG, Google Play, Apple App Store) via le flux
<a href="https://feed.eikowagenknecht.com/lootscraper.xml" target="_blank" rel="noopener" class="text-amber-700 dark:text-amber-300 hover:underline">LootScraper</a>.
Choisissez le canal Discord et les types de loot à notifier (PC, Android, iOS selon la source). Le bot vérifie le flux environ toutes les 30 minutes.
</p>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center gap-3 mb-6">
<span class="text-3xl">🎁</span>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Configuration FreeLoot</h2>
</div>
<form action="{{ url_for('updateFreeLoot') }}" method="POST" class="space-y-6">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="freeloot_enable" {% if configuration.getValue('freeloot_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer les notifications FreeLoot</span>
</label>
</div>
<div>
<label for="freeloot_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal Discord pour les notifications</label>
<select name="freeloot_channel_id" id="freeloot_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-amber-500 focus:border-transparent transition-all">
<option value="">— Choisir un canal —</option>
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('freeloot_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Mentions (optionnel)</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Choisissez qui mentionner au début du message (avant lembed).</p>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="freeloot_mention_everyone" {% if mention_everyone %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">@everyone</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="freeloot_mention_here" {% if mention_here %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">@here</span>
</label>
</div>
{% if roles %}
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôles à mentionner</p>
{% if roles|length > 1 %}
<div class="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-600 mb-3">
{% for guild_data in roles %}
<button type="button" class="freeloot-role-tab px-4 py-2 text-sm font-medium rounded-t-lg transition-colors {% if loop.first %}bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}"
data-tab="freeloot-roles-{{ guild_data.guild_id }}" {% if loop.first %}data-default{% endif %}>
{{ guild_data.guild_name }}
</button>
{% endfor %}
</div>
{% endif %}
{% for guild_data in roles %}
<div id="freeloot-roles-{{ guild_data.guild_id }}" class="freeloot-role-panel {% if not loop.first %}hidden{% endif %} max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-lg p-3 space-y-2">
{% for role in guild_data.roles %}
<label class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600/50 p-1 rounded">
<input type="checkbox" name="freeloot_mention_roles" value="{{ role.id }}"
{% if role.id|string in mention_role_ids %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
{% if role.color is defined and role.color is not none and role.color.value != 0 %}
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color:#{{ '%06x'|format(role.color.value) }}"></span>
{% else %}
<span class="w-3 h-3 rounded-full flex-shrink-0 bg-gray-400"></span>
{% endif %}
<span class="text-sm text-gray-700 dark:text-gray-300">{{ role.name }}</span>
</label>
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Types de loot à notifier</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Cochez les sources et plateformes pour lesquelles vous voulez recevoir une notification.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{% for key, label, emoji in sources %}
<label class="flex items-center gap-2 cursor-pointer p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600/50 transition-colors">
<input type="checkbox" name="freeloot_sources" value="{{ key }}"
{% if not enabled_sources or key in enabled_sources %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
<span class="text-lg" title="{{ label }}">{{ emoji }}</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ label }}</span>
</label>
{% endfor %}
</div>
</div>
<button type="submit"
class="px-6 py-2.5 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer
</button>
</form>
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-3">Aperçu de lembed Discord (style DraftBot)</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Exemple du message envoyé dans le canal.</p>
<div class="inline-block rounded-r-lg overflow-hidden border border-gray-300 dark:border-gray-600 bg-[#2f3136] max-w-lg shadow-lg" style="border-left: 4px solid #E67E22;">
<div class="p-4">
<div class="flex items-start gap-2 mb-2">
<a href="#" class="text-[#00a8fc] hover:underline font-semibold text-base flex-1">Definitely Not Fried Chicken gratuit sur l'Epic Games Store !</a>
<img src="https://store.epicgames.com/favicon.ico" alt="" class="w-10 h-10 rounded flex-shrink-0" title="Logo boutique">
</div>
<p class="text-[#dcddde] text-sm leading-relaxed mb-3">Definitely Not Fried Chicken is a business management sim with a twist! Grow your drugs trade through legitimate fronts, managing both sides of the business. Acquire new "businesses", meet new clientele, develop more potent narcotics…</p>
<div class="text-sm mb-2">
<span class="text-[#b9bbbe]">Prix</span>
<p class="text-[#dcddde]"><strong>Gratuit</strong> • jusqu'au 05/02/2026</p>
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm mb-2">
<div><span class="text-[#b9bbbe]">Prix recommandé</span><p class="text-[#dcddde]">39.99 EUR</p></div>
<div><span class="text-[#b9bbbe]">Genres</span><p class="text-[#dcddde]">Simulation, Indie</p></div>
<div><span class="text-[#b9bbbe]">Ratings</span><p class="text-[#dcddde]">PEGI 18, USK 18</p></div>
</div>
<p class="mb-3"><a href="#" class="text-[#00a8fc] hover:underline text-sm">Ouvrir dans la boutique !</a></p>
<div class="rounded overflow-hidden bg-[#202225] aspect-video flex items-center justify-center my-2">
<span class="text-4xl text-gray-500">🎁</span>
</div>
<p class="text-xs text-[#72767d] pt-1">MamieHenriette • FreeLoot</p>
</div>
</div>
</div>
</div>
<script>
document.querySelectorAll('.freeloot-role-tab').forEach(btn => {
btn.addEventListener('click', function() {
var tabId = this.getAttribute('data-tab');
document.querySelectorAll('.freeloot-role-panel').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('.freeloot-role-tab').forEach(b => {
b.classList.remove('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
b.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
});
document.getElementById(tabId).classList.remove('hidden');
this.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
this.classList.add('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
});
});
document.querySelector('.freeloot-role-tab[data-default]')?.click();
</script>
{% if entries %}
<div class="mt-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Jeux gratuits actuellement disponibles</h2>
</div>
<div class="p-6 overflow-x-auto">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{% for e in entries %}
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md transition-shadow flex flex-col">
<div class="aspect-video bg-gray-100 dark:bg-gray-700 flex items-center justify-center overflow-hidden">
{% if e.image_url %}
<img src="{{ e.image_url }}" alt="" class="w-full h-full object-cover" loading="lazy" />
{% else %}
<span class="text-4xl text-gray-400 dark:text-gray-500">🎁</span>
{% endif %}
</div>
<div class="p-4 flex flex-col flex-1 border-t border-gray-200 dark:border-gray-700">
<h3 class="font-semibold text-gray-900 dark:text-white mb-1 line-clamp-2" title="{{ e.game_name }}">{{ e.game_name }}</h3>
<p class="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 mb-2">
<span>{{ e.emoji }}</span>
<span>{{ e.source_label }}</span>
</p>
{% if e.recommended_price or e.genres or e.rating %}
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-0.5 mb-2">
{% if e.recommended_price %}<p><span class="text-gray-500 dark:text-gray-500">Prix recommandé:</span> {{ e.recommended_price }}</p>{% endif %}
{% if e.genres %}<p><span class="text-gray-500 dark:text-gray-500">Genres:</span> {{ e.genres }}</p>{% endif %}
{% if e.rating %}<p><span class="text-gray-500 dark:text-gray-500">Ratings:</span> {{ e.rating }}</p>{% endif %}
</div>
{% endif %}
{% if e.updated_formatted %}
<p class="text-xs text-gray-500 dark:text-gray-500 mb-3">{{ e.updated_formatted }}</p>
{% endif %}
<div class="mt-auto flex flex-col gap-2">
<form action="{{ url_for('send_free_loot_to_discord') }}" method="POST" class="w-full">
<input type="hidden" name="entry_id" value="{{ e.id }}">
<button type="submit" class="w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>
Envoyer sur Discord
</button>
</form>
{% if e.link %}
<a href="{{ e.link }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center gap-2 w-full px-3 py-2 text-sm font-medium rounded-lg bg-amber-600 hover:bg-amber-700 text-white transition-colors focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Ouvrir dans la boutique
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
</a>
{% endif %}
</div>
</div>
</article>
{% endfor %}
</div>
</div>
</div>
</div>
{% else %}
<div class="mt-8 p-4 rounded-lg bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700">
<p class="text-gray-600 dark:text-gray-400 text-sm">Le flux LootScraper na pas pu être chargé. Réessayez plus tard.</p>
</div>
{% endif %}
{% endblock %}
+57 -32
View File
@@ -1,42 +1,67 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Humeurs</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Statuts Discord qui changeront automatiquement toutes les 10 minutes.
</p>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
{% if humeurs %}
<ul class="divide-y divide-slate-200 dark:divide-slate-700">
{% for humeur in humeurs %}
<li class="flex items-center justify-between px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors group">
<span class="text-sm text-slate-700 dark:text-slate-300">{{ humeur.text }}</span>
<a href="{{ url_for('delHumeur', id = humeur.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette humeur ?')" class="p-1.5 text-slate-400 hover:text-red-500 dark:hover:text-red-400 rounded opacity-0 group-hover:opacity-100 transition-all" title="Supprimer">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<div class="px-4 py-8 text-center">
<p class="text-sm text-slate-500 dark:text-slate-400">Aucune humeur configurée</p>
<div 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>
{% endif %}
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-5">
<h2 class="text-lg font-medium text-slate-800 dark:text-white mb-4">Ajouter une humeur</h2>
<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">
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex-1">
<input type="text" name="text" id="text" placeholder="Joue à un super jeu..." class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
Ajouter
<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>
+56 -71
View File
@@ -10,84 +10,69 @@
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-10">
<a href="/live-alert" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
</div>
<div>
<h3 class="font-medium text-slate-800 dark:text-white">Alertes Live</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">Notifications Twitch</p>
</div>
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden mb-8">
<div class="p-4 sm:p-6 border-b border-slate-200 dark:border-slate-700 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<span class="inline-flex items-center justify-center w-3 h-3 rounded-full {% if discord_connected %}bg-emerald-500 ring-4 ring-emerald-500/30{% else %}bg-slate-400 ring-4 ring-slate-400/30{% endif %}" title="{% if discord_connected %}Bot Discord connecté{% else %}Bot Discord déconnecté{% endif %}"></span>
<h2 class="text-xl font-semibold text-slate-800 dark:text-white">Discord</h2>
</div>
</a>
<span class="text-sm text-slate-500 dark:text-slate-400">
{% if discord_connected %}Connecté{% else %}Déconnecté{% endif %}
</span>
</div>
<div class="p-4 sm:p-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Serveurs connectés</p>
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ discord_guild_count }}</p>
</div>
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Sanctions enregistrées</p>
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ sanctions_count }}</p>
</div>
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600 sm:col-span-2 lg:col-span-2 flex items-center justify-center">
<div class="flex flex-wrap gap-3 justify-center">
<a href="/live-alert" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Alertes Live</a>
<a href="/youtube" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Notification YouTube</a>
<a href="/humeurs" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Humeurs</a>
<a href="/protondb" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">ProtonDB</a>
<a href="/commandes" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Commandes</a>
<a href="/moderation" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Modération</a>
</div>
</div>
</div>
</div>
<a href="/commandes" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
</div>
<div>
<h3 class="font-medium text-slate-800 dark:text-white">Commandes</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">Commandes personnalisées</p>
</div>
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden mb-8">
<div class="p-4 sm:p-6 border-b border-slate-200 dark:border-slate-700 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<span class="inline-flex items-center justify-center w-3 h-3 rounded-full {% if twitch_connected %}bg-emerald-500 ring-4 ring-emerald-500/30{% else %}bg-slate-400 ring-4 ring-slate-400/30{% endif %}" title="{% if twitch_connected %}Bot Twitch connecté{% else %}Bot Twitch déconnecté{% endif %}"></span>
<h2 class="text-xl font-semibold text-slate-800 dark:text-white">Twitch</h2>
</div>
</a>
<a href="/humeurs" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
<div>
<h3 class="font-medium text-slate-800 dark:text-white">Humeurs</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">Statuts Discord rotatifs</p>
</div>
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
<span class="text-sm text-slate-500 dark:text-slate-400">
{% if twitch_connected %}Connecté{% else %}Déconnecté{% endif %}
</span>
</div>
<div class="p-4 sm:p-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Canal connecté</p>
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{% if twitch_channel_name %}{{ twitch_channel_name }}{% else %}—{% endif %}</p>
</div>
</a>
<a href="/moderation" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
</div>
<div>
<h3 class="font-medium text-slate-800 dark:text-white">Modération</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">Historique et actions</p>
</div>
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Annonces configurées</p>
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ twitch_announcements_count }}</p>
</div>
</a>
<a href="/protondb" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
</div>
<div>
<h3 class="font-medium text-slate-800 dark:text-white">ProtonDB</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">Compatibilité Linux</p>
</div>
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
<div 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>
</a>
<a href="/configurations" class="group bg-white dark:bg-slate-800 rounded-lg p-5 border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-md transition-all">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
<div 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>
<h3 class="font-medium text-slate-800 dark:text-white">Configurations</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">Paramètres du bot</p>
</div>
<svg class="w-5 h-5 text-slate-400 ml-auto opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</div>
</a>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 border border-slate-200 dark:border-slate-700">
@@ -98,7 +83,7 @@
<div>
<h3 class="font-medium text-slate-800 dark:text-white mb-2">À propos</h3>
<p class="text-sm text-slate-600 dark:text-slate-400 mb-4">
Mamie Henriette est un bot open source pour Discord et Twitch, développé par la communauté.
Mamie Henriette est un bot open source pour Discord et Twitch, développé par la communauté.
Cette interface vous permet de configurer et gérer toutes les fonctionnalités.
</p>
<div class="flex flex-wrap gap-3">
+176
View File
@@ -0,0 +1,176 @@
{% extends "template.html" %}
{% block content %}
<div class="space-y-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Filtre de liens</h1>
<p class="text-gray-600 dark:text-gray-400">Bloquez les liens non autorises sur votre chat Twitch.</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="p-3 rounded-lg {{ 'bg-green-100 dark:bg-green-900/30' if config.enabled else 'bg-gray-100 dark:bg-gray-700' }}">
<svg class="w-6 h-6 {{ 'text-green-600 dark:text-green-400' if config.enabled else 'text-gray-500' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Protection des liens</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'Active' if config.enabled else 'Desactive' }}</p>
</div>
</div>
<a href="{{ url_for('toggle_link_filter') }}" class="px-4 py-2 rounded-lg font-medium transition-colors {{ 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' if config.enabled else 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' }}">
{{ 'Desactiver' if config.enabled else 'Activer' }}
</a>
</div>
<form action="{{ url_for('update_link_filter') }}" method="POST" class="space-y-6">
<div class="grid md:grid-cols-2 gap-6">
<div class="space-y-4">
<h3 class="font-medium text-gray-900 dark:text-white">Autoriser les liens pour</h3>
<label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input type="checkbox" name="allow_moderators" {{ 'checked' if config.allow_moderators }} class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
<div>
<span class="text-gray-900 dark:text-white font-medium">Moderateurs</span>
<p class="text-xs text-gray-500 dark:text-gray-400">Les modos peuvent toujours poster des liens</p>
</div>
</label>
<label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input type="checkbox" name="allow_vips" {{ 'checked' if config.allow_vips }} class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
<div>
<span class="text-gray-900 dark:text-white font-medium">VIP</span>
<p class="text-xs text-gray-500 dark:text-gray-400">Les VIP peuvent poster des liens</p>
</div>
</label>
<label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input type="checkbox" name="allow_subscribers" {{ 'checked' if config.allow_subscribers }} class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
<div>
<span class="text-gray-900 dark:text-white font-medium">Abonnes</span>
<p class="text-xs text-gray-500 dark:text-gray-400">Les abonnes peuvent poster des liens</p>
</div>
</label>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Duree du timeout (secondes)</label>
<input type="number" name="timeout_duration" value="{{ config.timeout_duration }}" min="0" max="1209600"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">0 = pas de timeout, juste suppression du message</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message d'avertissement</label>
<textarea name="warning_message" rows="2"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none">{{ config.warning_message }}</textarea>
</div>
</div>
</div>
<button type="submit" class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium">
Enregistrer les parametres
</button>
</form>
</div>
<div class="grid md:grid-cols-2 gap-6">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<h3 class="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
</svg>
Domaines autorises ({{ domains|length }})
</h3>
</div>
<form action="{{ url_for('add_allowed_domain') }}" method="POST" class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex gap-2">
<input type="text" name="domain" placeholder="exemple.com" required
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm">
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors text-sm font-medium">
Ajouter
</button>
</div>
</form>
{% if domains %}
<div class="max-h-48 overflow-y-auto">
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{% for domain in domains %}
<div class="px-4 py-2 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
<code class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ domain.domain }}</code>
<a href="{{ url_for('delete_allowed_domain', domain_id=domain.id) }}" class="text-red-600 hover:text-red-700 text-sm">Supprimer</a>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400 text-sm">
Aucun domaine autorise
</div>
{% endif %}
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<h3 class="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
Viewers autorises ({{ users|length }})
</h3>
</div>
<form action="{{ url_for('add_allowed_user') }}" method="POST" class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex gap-2">
<input type="text" name="username" placeholder="@pseudo" required
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm">
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium">
Ajouter
</button>
</div>
</form>
{% if users %}
<div class="max-h-48 overflow-y-auto">
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{% for user in users %}
<div class="px-4 py-2 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
<span class="text-sm text-gray-700 dark:text-gray-300">@{{ user.username }}</span>
<a href="{{ url_for('delete_allowed_user', user_id=user.id) }}" class="text-red-600 hover:text-red-700 text-sm">Supprimer</a>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400 text-sm">
Aucun viewer en liste blanche
</div>
{% endif %}
</div>
</div>
<div class="bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 rounded-xl p-6 border border-blue-200 dark:border-blue-800">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Commande Permit
</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4">
Utilisez la commande <code class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs">!permit</code> pour autoriser temporairement un viewer a poster un lien.
</p>
<div class="flex flex-wrap gap-4 text-sm">
<div class="flex items-center gap-2">
<code class="px-2 py-1 bg-white dark:bg-gray-800 rounded text-xs font-mono text-purple-600 dark:text-purple-400">!permit @viewer</code>
<span class="text-gray-600 dark:text-gray-400">Autorise 1 min</span>
</div>
<div class="flex items-center gap-2">
<code class="px-2 py-1 bg-white dark:bg-gray-800 rounded text-xs font-mono text-purple-600 dark:text-purple-400">!permit @viewer 5</code>
<span class="text-gray-600 dark:text-gray-400">Autorise 5 min</span>
</div>
</div>
</div>
</div>
{% endblock %}
+314 -114
View File
@@ -1,135 +1,335 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Alertes Live</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Chaînes Twitch surveillées. Vérification toutes les 5 minutes, maximum 100 chaînes.
</p>
<div 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 %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Alertes configurées</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Chaîne</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Canal Discord</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Message</th>
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
{% for alert in alerts %}
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
<td class="px-4 py-3">
<a href="https://www.twitch.tv/{{ alert.login }}" target="_blank" class="text-sm font-medium text-slate-700 dark:text-slate-300 hover:underline">{{ alert.login }}</a>
</td>
<td class="px-4 py-3">
<span class="text-sm text-slate-600 dark:text-slate-400">#{{ alert.notify_channel_name }}</span>
</td>
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400 max-w-xs">
<div class="line-clamp-2">{{ alert.message }}</div>
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('toggleLiveAlert', id = alert.id) }}" class="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors" title="{{ 'Désactiver' if alert.enable else 'Activer' }}">
{% if alert.enable %}
<span class="text-green-600 dark:text-green-500">Actif</span>
{% else %}
<span class="text-slate-400">Inactif</span>
{% endif %}
<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>
<a href="{{ url_for('openEditLiveAlert', id = alert.id) }}" class="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors">
Modifier
</a>
<a href="{{ url_for('delLiveAlert', id = alert.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette alerte ?')" class="text-sm text-slate-500 hover:text-red-600 dark:hover:text-red-400 transition-colors">
Supprimer
</a>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
Aucune alerte configurée
</td>
</tr>
{% endfor %}
</tbody>
</table>
</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 %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-5">
<h2 class="text-lg font-medium text-slate-800 dark:text-white mb-5">
{% if alert %}Modifier l'alerte{% else %}Ajouter une alerte{% endif %}
<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>
<form action="{{ url_for('submitEditLiveAlert', id = alert.id) if alert else url_for('addLiveAlert') }}" method="POST" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="login" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Chaîne Twitch</label>
<input type="text" name="login" id="login" maxlength="32" required value="{{ alert.login if alert else '' }}" placeholder="chainesteve" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">Le login de la chaîne, ex: chainesteve</p>
</div>
<div>
<label for="notify_channel" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Canal de Notification</label>
<select name="notify_channel" id="notify_channel" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
{% for channel in channels %}
<option value="{{ channel.id }}" {% if alert and alert.notify_channel == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<label for="message" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Message de notification</label>
<textarea name="message" id="message" rows="4" required placeholder="🔴 **{0.user_name}** est en live !" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all resize-none">{{ alert.message if alert else '' }}</textarea>
<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 class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4">
<p class="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">Variables disponibles :</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
<div class="flex items-center gap-2">
<code class="px-2 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs font-mono">{0.user_login}</code>
<span class="text-slate-600 dark:text-slate-400">Lien vers la chaîne</span>
<div>
<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>
<div class="flex items-center gap-2">
<code class="px-2 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs font-mono">{0.user_name}</code>
<span class="text-slate-600 dark:text-slate-400">Nom du streamer</span>
<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 class="flex items-center gap-2">
<code class="px-2 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs font-mono">{0.game_name}</code>
<span class="text-slate-600 dark:text-slate-400">Jeu en cours</span>
</div>
<div class="flex items-center gap-2">
<code class="px-2 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs font-mono">{0.title}</code>
<span class="text-slate-600 dark:text-slate-400">Titre du stream</span>
</div>
<div class="flex items-center gap-2">
<code class="px-2 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs font-mono">{0.language}</code>
<span class="text-slate-600 dark:text-slate-400">Langue du stream</span>
<div 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 class="flex items-center justify-end gap-3">
{% if alert %}
<a href="{{ url_for('openLiveAlert') }}" class="px-4 py-2 text-slate-700 dark:text-slate-300 text-sm font-medium rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
Annuler
</a>
{% endif %}
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
{% if alert %}Enregistrer{% else %}Ajouter{% endif %}
</button>
</div>
</form>
</div>
</div>
<script>
function formatText(text, vars) {
if (!text) return '';
return text.replace(/\{(\w+)\}/g, function(match, key) {
return vars[key] !== undefined && vars[key] !== null ? vars[key] : match;
});
}
function updatePreview() {
const embedTitle = document.getElementById('embed_title').value || '{title}';
const embedDescription = document.getElementById('embed_description').value || '';
const embedColor = document.getElementById('embed_color_text').value || '9146FF';
const embedAuthorName = document.getElementById('embed_author_name').value || '{user_name}';
const embedAuthorIcon = document.getElementById('embed_author_icon').value || '';
const embedFooter = document.getElementById('embed_footer').value || '';
const embedThumbnail = document.getElementById('embed_thumbnail').checked;
const embedImage = document.getElementById('embed_image').checked;
const vars = {
user_login: 'chainesteve',
user_name: 'ChaîneSteve',
game_name: 'Minecraft',
title: '🔴 Live chill avec les viewers',
language: 'fr',
stream_url: 'https://www.twitch.tv/chainesteve',
thumbnail: 'https://static-cdn.jtvnw.net/previews-ttv/live_user_chainesteve-320x180.jpg'
};
document.getElementById('embed-title').textContent = formatText(embedTitle, vars);
document.getElementById('embed-title').href = vars.stream_url;
document.getElementById('embed-description').textContent = formatText(embedDescription, vars);
document.getElementById('embed-author-name').textContent = formatText(embedAuthorName, vars);
document.getElementById('embed-author-icon').src = embedAuthorIcon || 'https://static-cdn.jtvnw.net/ttv-favicon/favicon-32x32.png';
document.getElementById('embed-author-icon').style.display = embedAuthorIcon ? '' : 'none';
document.getElementById('embed-footer').textContent = formatText(embedFooter, vars);
document.getElementById('embed-preview').style.borderLeftColor = '#' + embedColor;
if (embedThumbnail) {
document.getElementById('embed-thumbnail').src = vars.thumbnail;
document.getElementById('embed-thumbnail').style.display = 'block';
} else {
document.getElementById('embed-thumbnail').style.display = 'none';
}
if (embedImage) {
document.getElementById('embed-image').src = vars.thumbnail;
document.getElementById('embed-image').style.display = 'block';
} else {
document.getElementById('embed-image').style.display = 'none';
}
}
const colorInput = document.getElementById('embed_color');
if (colorInput) {
colorInput.addEventListener('input', function(e) {
document.getElementById('embed_color_text').value = e.target.value.substring(1).toUpperCase();
updatePreview();
});
}
const colorText = document.getElementById('embed_color_text');
if (colorText) {
colorText.addEventListener('input', function(e) {
const val = e.target.value.replace(/[^0-9A-Fa-f]/g, '').substring(0, 6);
e.target.value = val;
if (val.length === 6) {
document.getElementById('embed_color').value = '#' + val;
updatePreview();
}
});
}
const formFields = ['embed_title', 'embed_description', 'embed_author_name', 'embed_author_icon', 'embed_footer', 'embed_thumbnail', 'embed_image'];
formFields.forEach(function(field) {
const el = document.getElementById(field);
if (el) {
el.addEventListener('input', updatePreview);
el.addEventListener('change', updatePreview);
}
});
updatePreview();
</script>
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends "template.html" %}
{% block content %}
<div class="max-w-md mx-auto py-12">
<h1 class="text-2xl font-bold text-slate-800 dark:text-white mb-6 text-center">Connexion</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-4 space-y-2">
{% for category, msg in messages %}
<p class="p-3 rounded-lg text-sm {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200{% endif %}">{{ msg }}</p>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('login') }}" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 space-y-4">
<div>
<label for="identifier" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Identifiant (nom d'utilisateur ou e-mail)</label>
<input type="text" id="identifier" name="identifier" required autocomplete="username" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="nom ou email">
</div>
<div>
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Mot de passe</label>
<input type="password" id="password" name="password" required autocomplete="current-password" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent">
</div>
<button type="submit" class="w-full py-2.5 px-4 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">Se connecter</button>
</form>
{% if registration_enabled %}
<p class="mt-4 text-center text-sm text-slate-600 dark:text-slate-400">
Pas encore de compte ? <a href="{{ url_for('register') }}" class="text-primary-600 dark:text-primary-400 hover:underline">Créer un compte</a>
</p>
{% endif %}
</div>
{% endblock %}
+47
View File
@@ -8,6 +8,53 @@
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Top 3 sanctions</h2>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Utilisateurs les plus sanctionnés</p>
</div>
<div class="divide-y divide-slate-200 dark:divide-slate-700">
{% for row in top_sanctioned %}
<div class="px-5 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<span class="flex-shrink-0 w-7 h-7 rounded-full bg-slate-200 dark:bg-slate-600 flex items-center justify-center text-sm font-bold text-slate-700 dark:text-slate-300">{{ loop.index }}</span>
<div class="min-w-0">
<span class="block text-sm font-medium text-slate-800 dark:text-white truncate">{{ row.username or '—' }}</span>
<span class="block text-xs text-slate-500 dark:text-slate-400 font-mono truncate">{{ row.discord_id }}</span>
</div>
</div>
<span class="flex-shrink-0 text-sm font-semibold text-slate-600 dark:text-slate-300">{{ row.count }} sanction{{ 's' if row.count > 1 else '' }}</span>
</div>
{% else %}
<div class="px-5 py-6 text-center text-sm text-slate-500 dark:text-slate-400">Aucune sanction enregistrée</div>
{% endfor %}
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Top 3 modérateurs</h2>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Staff ayant effectué le plus d'actions</p>
</div>
<div class="divide-y divide-slate-200 dark:divide-slate-700">
{% for row in top_moderators %}
<div class="px-5 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<span class="flex-shrink-0 w-7 h-7 rounded-full bg-slate-200 dark:bg-slate-600 flex items-center justify-center text-sm font-bold text-slate-700 dark:text-slate-300">{{ loop.index }}</span>
<div class="min-w-0">
<span class="block text-sm font-medium text-slate-800 dark:text-white truncate">{{ row.staff_name or '—' }}</span>
<span class="block text-xs text-slate-500 dark:text-slate-400 font-mono truncate">{{ row.staff_id }}</span>
</div>
</div>
<span class="flex-shrink-0 text-sm font-semibold text-slate-600 dark:text-slate-300">{{ row.count }} action{{ 's' if row.count > 1 else '' }}</span>
</div>
{% else %}
<div class="px-5 py-6 text-center text-sm text-slate-500 dark:text-slate-400">Aucune action enregistrée</div>
{% endfor %}
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
<details class="group">
<summary class="flex items-center justify-between px-5 py-4 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
+124 -113
View File
@@ -1,124 +1,135 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">ProtonDB</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Compatibilité des jeux Windows sur Linux via Steam Play.
</p>
</div>
{% if configuration.getValue('proton_db_enable_enable') %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Alias de jeux</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Alias</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Nom du jeu</th>
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
{% for a in aliases %}
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
<td class="px-4 py-3">
<code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded text-xs font-mono">{{ a.alias }}</code>
</td>
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400">
{{ a.name }}
</td>
<td class="px-4 py-3 text-right">
<a href="{{ url_for('delGameAlias', id = a.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet alias ?')" class="text-sm text-slate-500 hover:text-red-600 dark:hover:text-red-400 transition-colors">
Supprimer
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="px-4 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
Aucun alias configuré
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-5 mb-6">
<h2 class="text-lg font-medium text-slate-800 dark:text-white mb-5">Ajouter un alias</h2>
<form action="{{ url_for('addGameAlias') }}" method="POST" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="alias" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Alias</label>
<input type="text" name="alias" id="alias" maxlength="32" required placeholder="GTA" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
<div>
<label for="name" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Nom complet</label>
<input type="text" name="name" id="name" maxlength="256" required placeholder="Grand Theft Auto" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
Ajouter
</button>
</div>
</form>
<div class="mt-5 bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4">
<p class="text-sm text-slate-600 dark:text-slate-400">
<strong class="text-slate-800 dark:text-white">Exemple :</strong> Si vous créez un alias GTA → Grand Theft Auto, alors !protondb GTA 5 fera une recherche sur Grand Theft Auto 5.
<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>
{% endif %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Configuration</h2>
</div>
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="p-5 space-y-6">
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" name="proton_db_enable_enable" {% if configuration.getValue('proton_db_enable_enable') %}checked{% endif %} class="w-4 h-4 text-slate-600 bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 rounded focus:ring-slate-500">
<span class="text-sm text-slate-700 dark:text-slate-300">Activer la commande ProtonDB</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="proton_db_api_id" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">API ID</label>
<input type="text" name="proton_db_api_id" id="proton_db_api_id" value="{{ configuration.getValue('proton_db_api_id') }}" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
<div>
<label for="proton_db_api_key" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Clé API</label>
<input type="text" name="proton_db_api_key" id="proton_db_api_key" value="{{ configuration.getValue('proton_db_api_key') }}" class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
</div>
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
Enregistrer
</button>
</form>
<div class="px-5 pb-5">
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4">
<p class="text-sm font-medium text-slate-800 dark:text-white mb-2">Comment trouver les clés API ?</p>
<ol class="list-decimal list-inside space-y-1 text-sm text-slate-600 dark:text-slate-400">
<li>Ouvrez l'outil d'inspection de votre navigateur (F12)</li>
<li>Allez dans l'onglet Réseau/Network</li>
<li>Faites une recherche de jeu sur ProtonDB</li>
<li>Cherchez les clés dans les requêtes réseau</li>
</ol>
<a href="/static/img/algolia-key.jpg" target="_blank" class="inline-block mt-3 text-sm text-slate-600 dark:text-slate-400 hover:underline">
Voir l'exemple en image
</a>
{% if configuration.getValue('proton_db_enable_enable') %}
<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>
<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 %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Configuration</h2>
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-6">
<div class="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<input type="checkbox" name="proton_db_enable_enable" id="proton_db_enable_enable"
{% if configuration.getValue('proton_db_enable_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<label for="proton_db_enable_enable" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
Activer la commande ProtonDB
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="proton_db_api_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API ID</label>
<input name="proton_db_api_id" id="proton_db_api_id" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('proton_db_api_id') }}"/>
</div>
<div>
<label for="proton_db_api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Clé API</label>
<input name="proton_db_api_key" id="proton_db_api_key" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('proton_db_api_key') }}"/>
</div>
</div>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<p class="text-sm text-amber-800 dark:text-amber-200">
Pour trouver les clés : ouvrez l'outil d'inspection (F12) dans votre navigateur, faites une recherche de jeux sur ProtonDB,
puis cherchez les clés dans les requêtes (onglet Réseau/Network).
<a href="/static/img/algolia-key.jpg" target="_blank" class="underline hover:no-underline">Voir l'exemple</a>
</p>
</div>
<div>
<button type="submit"
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer la configuration
</button>
</div>
</form>
</div>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
{% extends "template.html" %}
{% block content %}
<div class="max-w-md mx-auto py-12">
<h1 class="text-2xl font-bold text-slate-800 dark:text-white mb-6 text-center">Créer un compte</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-4 space-y-2">
{% for category, msg in messages %}
<p class="p-3 rounded-lg text-sm {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200{% endif %}">{{ msg }}</p>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('register') }}" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Nom d'utilisateur</label>
<input type="text" id="username" name="username" required minlength="3" autocomplete="username" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="min. 3 caractères">
</div>
<div>
<label for="email" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Adresse e-mail</label>
<input type="email" id="email" name="email" required autocomplete="email" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="vous@exemple.com">
</div>
<div>
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Mot de passe</label>
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="min. 8 caractères">
</div>
<div>
<label for="password_confirm" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Confirmer le mot de passe</label>
<input type="password" id="password_confirm" name="password_confirm" required minlength="8" autocomplete="new-password" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent">
</div>
<button type="submit" class="w-full py-2.5 px-4 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">S'inscrire</button>
</form>
<p class="mt-4 text-center text-sm text-slate-600 dark:text-slate-400">
Déjà un compte ? <a href="{{ url_for('login') }}" class="text-primary-600 dark:text-primary-400 hover:underline">Se connecter</a>
</p>
</div>
{% endblock %}
+386
View File
@@ -0,0 +1,386 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Paramètres et Permissions</h1>
<p class="text-gray-600 dark:text-gray-400">Configuration des rôles et des accès aux pages (super administrateur uniquement)</p>
</div>
<button type="button" onclick="document.getElementById('help-modal').classList.remove('hidden')"
class="px-4 py-2 rounded-lg bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 hover:bg-primary-200 dark:hover:bg-primary-900/50 transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Aide</span>
</button>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-6 space-y-2">
{% for category, msg in messages %}
<div class="p-4 rounded-lg {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800{% endif %}">
{{ msg }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="space-y-8">
<!-- Inscriptions -->
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<div class="flex-grow">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Inscriptions</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Autoriser ou bloquer la création de nouveaux comptes par les visiteurs</p>
<form action="{{ url_for('settings_toggle_registration') }}" method="post">
<label class="inline-flex items-center gap-3 cursor-pointer group">
<div class="relative">
<input type="checkbox" name="enabled" value="1" {% if registration_enabled %}checked{% endif %}
onchange="this.form.submit()"
class="sr-only peer">
<div class="w-14 h-7 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-green-500 dark:peer-checked:bg-green-600 transition-colors"></div>
<div class="absolute left-1 top-1 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-7 shadow-md"></div>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white">
{% if registration_enabled %}✓ Inscriptions activées{% else %}✗ Inscriptions désactivées{% endif %}
</span>
</label>
</form>
</div>
</div>
</section>
<!-- Rôles -->
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div class="flex-grow">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Hiérarchie des rôles</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
Les rôles définissent le niveau d'accès des utilisateurs. Plus le niveau est élevé, plus les permissions sont étendues.
</p>
</div>
</div>
</div>
<div class="p-6">
<div class="space-y-3 mb-6">
{% for r in roles %}
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 transition-colors">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" style="background-color: {{ r.color or '#6B7280' }}20;">
<span class="text-2xl" style="color: {{ r.color or '#6B7280' }};"></span>
</div>
<div class="flex-grow min-w-0">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ r.name }}</h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
style="background-color: {{ r.color or '#6B7280' }}20; color: {{ r.color or '#6B7280' }};">
Niveau {{ r.level }}
</span>
</div>
{% if r.description %}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ r.description }}</p>
{% else %}
{% set default_desc = default_roles_meta.get(r.name, {}).get('description') %}
{% if default_desc %}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ default_desc }}</p>
{% endif %}
{% endif %}
<details class="group">
<summary class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 cursor-pointer list-none flex items-center gap-1">
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span>Modifier ce rôle</span>
</summary>
<form action="{{ url_for('settings_role_edit', role_id=r.id) }}" method="post" class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Niveau (0-99)</label>
<input type="number" name="level" value="{{ r.level }}" min="0" max="99"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Couleur</label>
<input type="color" name="color" value="{{ r.color or '#6B7280' }}"
class="w-full h-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 cursor-pointer">
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
<input type="text" name="description" value="{{ r.description or '' }}" placeholder="Description du rôle..."
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div class="sm:col-span-2 flex items-center gap-2">
<button type="submit" class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white text-sm font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
Enregistrer
</button>
{% if r.name not in ['viewer_twitch','utilisateur_discord','moderateur_discord','expert_discord','moderateur_twitch','super_administrateur'] %}
<button type="button" onclick="if(confirm('Supprimer ce rôle ?')) { this.closest('details').querySelector('form').setAttribute('action', '{{ url_for('settings_role_delete', role_id=r.id) }}'); this.closest('form').submit(); }"
class="px-4 py-2 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm font-medium hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors">
Supprimer
</button>
{% endif %}
</div>
</form>
</details>
</div>
</div>
</div>
{% endfor %}
</div>
<details class="bg-primary-50 dark:bg-primary-900/20 rounded-lg border border-primary-200 dark:border-primary-800">
<summary class="px-4 py-3 cursor-pointer list-none flex items-center gap-2 text-primary-700 dark:text-primary-300 font-medium hover:text-primary-800 dark:hover:text-primary-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span>Créer un nouveau rôle</span>
</summary>
<form action="{{ url_for('settings_role_add') }}" method="post" class="p-4 pt-0 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Nom du rôle *</label>
<input type="text" name="name" placeholder="ex: editeur_contenu" required minlength="2"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Niveau (0-99) *</label>
<input type="number" name="level" value="0" min="0" max="99"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Couleur</label>
<input type="color" name="color" value="#6B7280"
class="w-full h-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 cursor-pointer">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Icône (optionnel)</label>
<input type="text" name="icon" placeholder="ex: star, shield, user"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
<input type="text" name="description" placeholder="Description du rôle..."
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div class="sm:col-span-2">
<button type="submit" class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
Créer le rôle
</button>
</div>
</form>
</details>
</div>
</section>
<!-- Permissions par page -->
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div class="flex-grow">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Accès aux pages</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
Définissez le rôle minimum requis pour accéder à chaque section de l'interface
</p>
</div>
</div>
</div>
<!-- Appliquer en masse -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/30">
<details class="group">
<summary class="cursor-pointer list-none flex items-center gap-2 text-gray-700 dark:text-gray-300 font-medium hover:text-gray-900 dark:hover:text-white">
<svg class="w-5 h-5 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span>⚡ Modification en masse</span>
</summary>
<form action="{{ url_for('settings_permissions_bulk') }}" method="post" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
<div class="flex flex-wrap items-center gap-4 mb-4">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<span>Appliquer le rôle :</span>
<select name="role" required class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
{% for r in roles %}
<option value="{{ r.name }}" style="color: {{ r.color or '#6B7280' }};">{{ r.name }} (niveau {{ r.level }})</option>
{% endfor %}
</select>
</label>
<span class="text-sm text-gray-500 dark:text-gray-400">aux pages cochées :</span>
</div>
<div class="flex flex-wrap gap-2 mb-4">
<button type="button" onclick="document.querySelectorAll('.bulk-page-cb').forEach(c => c.checked = true)"
class="text-xs px-3 py-1.5 rounded-lg bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
Tout cocher
</button>
<button type="button" onclick="document.querySelectorAll('.bulk-page-cb').forEach(c => c.checked = false)"
class="text-xs px-3 py-1.5 rounded-lg bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
Tout décocher
</button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 mb-4">
{% for page_key, meta in page_metadata.items() %}
<label class="flex items-center gap-2 px-3 py-2 rounded-lg bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:border-primary-300 dark:hover:border-primary-600 cursor-pointer transition-colors">
<input type="checkbox" name="page_keys" value="{{ page_key }}" class="bulk-page-cb rounded border-gray-300 dark:border-gray-600 text-primary-600 focus:ring-primary-500">
<span class="text-xs text-gray-700 dark:text-gray-300">{{ meta.label }}</span>
</label>
{% endfor %}
</div>
<button type="submit" class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
Appliquer à la sélection
</button>
</form>
</details>
</div>
<!-- Pages par catégorie -->
<div class="p-6">
{% for category_key in ['general', 'content', 'moderation', 'config', 'admin'] %}
{% if category_key in pages_by_category %}
{% set category_info = category_labels[category_key] %}
<div class="mb-8 last:mb-0">
<div class="flex items-center gap-3 mb-4">
<div class="w-8 h-8 rounded-lg flex items-center justify-center" style="background-color: {{ category_info.color }}20;">
<span class="text-lg" style="color: {{ category_info.color }};"></span>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ category_info.label }}</h3>
<div class="flex-grow h-px bg-gray-200 dark:bg-gray-700"></div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for page_data in pages_by_category[category_key] %}
{% set page_key = page_data.key %}
{% set meta = page_data.meta %}
{% set perm = page_data.permission %}
{% set min_lvl = perm.min_level if perm else 0 %}
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 transition-colors">
<div class="flex items-start justify-between gap-3 mb-2">
<div>
<h4 class="font-medium text-gray-900 dark:text-white mb-1">{{ meta.label }}</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">{{ meta.description }}</p>
</div>
{% for r in roles %}
{% if r.level == min_lvl %}
<span class="flex-shrink-0 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap"
style="background-color: {{ r.color or '#6B7280' }}20; color: {{ r.color or '#6B7280' }};">
{{ r.name }}
</span>
{% endif %}
{% endfor %}
</div>
<form action="{{ url_for('settings_permissions_update') }}" method="post" class="flex items-center gap-2">
<input type="hidden" name="page_key" value="{{ page_key }}">
<select name="role" class="flex-grow px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-xs focus:ring-2 focus:ring-primary-500">
{% for r in roles %}
<option value="{{ r.name }}" {% if r.level == min_lvl %}selected{% endif %}>{{ r.name }} (niv. {{ r.level }})</option>
{% endfor %}
</select>
<button type="submit" class="px-3 py-1.5 rounded-lg bg-primary-600 dark:bg-primary-500 text-white text-xs font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
OK
</button>
</form>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
</section>
</div>
<!-- Modal d'aide -->
<div id="help-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" onclick="document.getElementById('help-modal').classList.add('hidden')"></div>
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
<div class="bg-white dark:bg-gray-800 px-6 pt-6 pb-4">
<div class="flex items-start justify-between mb-4">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Guide des permissions</h3>
<button type="button" onclick="document.getElementById('help-modal').classList.add('hidden')"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-4 text-sm text-gray-600 dark:text-gray-400">
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">🎭 Rôles</h4>
<p>Les rôles définissent le niveau d'autorité d'un utilisateur. Plus le niveau est élevé, plus l'utilisateur a accès à des fonctionnalités.</p>
<ul class="list-disc list-inside mt-2 space-y-1 ml-4">
<li><strong>Niveau 0-1</strong> : Accès basique en lecture seule</li>
<li><strong>Niveau 2-3</strong> : Modification de contenu et gestion basique</li>
<li><strong>Niveau 4</strong> : Modération et configuration avancée</li>
<li><strong>Niveau 5+</strong> : Administration complète du système</li>
</ul>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">🔐 Permissions par page</h4>
<p>Chaque page peut être restreinte à un rôle minimum. Un utilisateur doit avoir au moins le niveau requis pour y accéder.</p>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">📋 Catégories</h4>
<ul class="list-disc list-inside space-y-1 ml-4">
<li><strong style="color: #6B7280;"></strong> <strong>Général</strong> : Pages d'accueil et navigation</li>
<li><strong style="color: #3B82F6;"></strong> <strong>Contenu</strong> : Gestion des commandes, alertes, humeurs, etc.</li>
<li><strong style="color: #EF4444;"></strong> <strong>Modération</strong> : Outils de modération Discord/Twitch</li>
<li><strong style="color: #8B5CF6;"></strong> <strong>Configuration</strong> : Paramètres techniques des bots</li>
<li><strong style="color: #F59E0B;"></strong> <strong>Administration</strong> : Gestion des utilisateurs et permissions</li>
</ul>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">💡 Bonnes pratiques</h4>
<ul class="list-disc list-inside space-y-1 ml-4">
<li>Attribuez le rôle le plus bas possible selon les besoins</li>
<li>Testez les permissions avec un compte utilisateur avant de les déployer</li>
<li>Documentez les rôles personnalisés avec des descriptions claires</li>
<li>Vérifiez régulièrement les accès des utilisateurs</li>
</ul>
</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4">
<button type="button" onclick="document.getElementById('help-modal').classList.add('hidden')"
class="w-full px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
Compris !
</button>
</div>
</div>
</div>
</div>
<style>
/* Améliorer le style du toggle switch */
input[type="checkbox"].sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
</style>
{% endblock %}
+151 -33
View File
@@ -77,46 +77,117 @@
<!-- Navigation Desktop -->
<div class="hidden md:flex items-center gap-1">
<a href="/live-alert" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
<span class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
Alerte live
</span>
<!-- Discord (sous-menus) -->
<div class="relative group">
<button type="button" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-1.5">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
Discord
<svg class="w-4 h-4 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</button>
<div class="absolute left-0 top-full pt-1 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 min-w-[200px]">
<a href="/humeurs" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
Humeur
</a>
<a href="/live-alert" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
Notification Twitch
</a>
<a href="{{ url_for('open_twitch_events') }}" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
Événements Twitch (sub, raid, clip)
</a>
<a href="/youtube" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
Notification YouTube
</a>
<a href="/protondb" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
ProtonDB
</a>
<a href="{{ url_for('openFreeLoot') }}" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<span class="text-lg">🎁</span>
FreeLoot
</a>
<div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
<a href="/commandes" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
Commandes
</a>
<a href="/moderation" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
Modération
</a>
<a href="/configurations#auto-rooms" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg>
Auto Rooms
</a>
</div>
</div>
</div>
<!-- Twitch (futur bot) -->
<div class="relative group">
<button type="button" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-1.5">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57l-.002-5.143zm3.43 0H16.714v5.143H15V4.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0H6zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714v9.429z"/></svg>
Twitch
<svg class="w-4 h-4 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</button>
<div class="absolute left-0 top-full pt-1 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 min-w-[200px]">
<a href="/live-alert" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
Alerte live
</a>
<a href="{{ url_for('open_twitch_events') }}" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
Événements (sub, raid, clip)
</a>
<a href="/announcements" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path></svg>
Annonces
</a>
<a href="/twitch-moderation" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
Moderation
</a>
<a href="/link-filter" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
Filtre de liens
</a>
</div>
</div>
</div>
<!-- Configuration locale -->
<a href="/configurations" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
Configuration
</a>
<a href="/commandes" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
<span class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
Commandes
</span>
{% 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="/humeurs" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
<span class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
Humeurs
</span>
</a>
<a href="/moderation" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
<span class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
Modération
</span>
</a>
<a href="/protondb" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
<span class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
ProtonDB
</span>
</a>
<a href="/configurations" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
<span class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
Configurations
</span>
<a 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>
@@ -138,6 +209,26 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
Alerte live
</a>
<a href="/announcements" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path></svg>
Annonces Twitch
</a>
<a href="/twitch-moderation" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
Moderation Twitch
</a>
<a href="/link-filter" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
Filtre de liens
</a>
<a href="{{ url_for('open_twitch_events') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
Événements Twitch (sub, raid, clip)
</a>
<a href="/youtube" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
YouTube
</a>
<a href="/commandes" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
Commandes
@@ -150,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>
+117
View File
@@ -0,0 +1,117 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Notifications d'événements Twitch</h1>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Configurez les notifications pour les abonnements, follows, raids et nouveaux clips.
Pour chaque type d'événement vous pouvez activer l'envoi dans le <strong>chat Twitch</strong> et/ou dans un <strong>canal Discord</strong>.
Les événements sub, follow et raid utilisent Twitch EventSub ; les clips sont détectés par vérification périodique.
</p>
</div>
</div>
<form action="{{ url_for('save_twitch_events') }}" method="POST" class="space-y-8">
{% for cfg in configs %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-wrap gap-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ labels[cfg.event_type] }}</h2>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="hidden" name="ev_{{ cfg.event_type }}_enable" value="0">
<input type="checkbox" name="ev_{{ cfg.event_type }}_enable" value="1" {{ 'checked' if cfg.enable }}
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer</span>
</label>
<a href="{{ url_for('toggle_twitch_event', event_type=cfg.event_type) }}"
class="text-sm {{ 'text-green-600 dark:text-green-400' if cfg.enable else 'text-gray-500' }}">
{{ 'Activé' if cfg.enable else 'Désactivé' }}
</a>
</div>
</div>
<div class="p-6 space-y-6">
<div class="grid md:grid-cols-2 gap-6">
<div class="space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Où notifier</h3>
<label class="flex items-center gap-3 cursor-pointer">
<input type="hidden" name="ev_{{ cfg.event_type }}_notify_twitch_chat" value="0">
<input type="checkbox" name="ev_{{ cfg.event_type }}_notify_twitch_chat" value="1" {{ 'checked' if cfg.notify_twitch_chat }}
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
<span class="text-gray-700 dark:text-gray-300">Chat Twitch</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input type="hidden" name="ev_{{ cfg.event_type }}_notify_discord" value="0">
<input type="checkbox" name="ev_{{ cfg.event_type }}_notify_discord" value="1" {{ 'checked' if cfg.notify_discord }}
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
<span class="text-gray-700 dark:text-gray-300">Discord (canal de notifs)</span>
</label>
<div>
<label for="ev_{{ cfg.event_type }}_discord_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Canal Discord</label>
<select name="ev_{{ cfg.event_type }}_discord_channel_id" id="ev_{{ cfg.event_type }}_discord_channel"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">— Choisir un canal —</option>
{% for ch in channels %}
<option value="{{ ch.id }}" {{ 'selected' if cfg.discord_channel_id == ch.id }}>{{ ch.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="space-y-4">
<div>
<label for="ev_{{ cfg.event_type }}_message_twitch" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message (chat Twitch)</label>
<input type="text" name="ev_{{ cfg.event_type }}_message_twitch" id="ev_{{ cfg.event_type }}_message_twitch" maxlength="500"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
value="{{ cfg.message_twitch }}" placeholder="Merci {user} !">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Sub/Follow: <code>{user}</code> <code>{user_name}</code>
Raid: <code>{from_broadcaster_name}</code> <code>{viewers}</code>
Clip: <code>{user}</code> <code>{title}</code> <code>{url}</code>
</p>
</div>
<div>
<label for="ev_{{ cfg.event_type }}_message_discord" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message Discord (optionnel, avant l'embed)</label>
<textarea name="ev_{{ cfg.event_type }}_message_discord" id="ev_{{ cfg.event_type }}_message_discord" rows="2" maxlength="2000"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-y">{{ cfg.message_discord or '' }}</textarea>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<h4 class="font-medium text-gray-800 dark:text-gray-200 mb-3">Embed Discord (optionnel)</h4>
<div class="grid sm:grid-cols-2 gap-4">
<div>
<label for="ev_{{ cfg.event_type }}_embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titre</label>
<input type="text" name="ev_{{ cfg.event_type }}_embed_title" id="ev_{{ cfg.event_type }}_embed_title" maxlength="256"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
value="{{ cfg.embed_title or '' }}" placeholder="Ex: Nouveau clip">
</div>
<div>
<label for="ev_{{ cfg.event_type }}_embed_color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Couleur (hex)</label>
<input type="text" name="ev_{{ cfg.event_type }}_embed_color" id="ev_{{ cfg.event_type }}_embed_color" maxlength="6"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono"
value="{{ cfg.embed_color or '9146FF' }}" placeholder="9146FF">
</div>
</div>
<div class="mt-3">
<label for="ev_{{ cfg.event_type }}_embed_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
<textarea name="ev_{{ cfg.event_type }}_embed_description" id="ev_{{ cfg.event_type }}_embed_description" rows="2" maxlength="2000"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-y">{{ cfg.embed_description or '' }}</textarea>
</div>
<label class="flex items-center gap-2 mt-3 cursor-pointer">
<input type="hidden" name="ev_{{ cfg.event_type }}_embed_thumbnail" value="0">
<input type="checkbox" name="ev_{{ cfg.event_type }}_embed_thumbnail" value="1" {{ 'checked' if cfg.embed_thumbnail }}
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
<span class="text-sm text-gray-700 dark:text-gray-300">Miniature dans l'embed (clips)</span>
</label>
</div>
</div>
</div>
{% endfor %}
<div class="flex justify-end">
<button type="submit" class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer tout
</button>
</div>
</form>
{% endblock %}
+964
View File
@@ -0,0 +1,964 @@
{% extends "template.html" %}
{% block content %}
<style>
.panel-moderation {
display: grid;
grid-template-columns: minmax(320px, 56%) 1fr;
gap: 1rem;
min-height: calc(100vh - 8rem);
}
@media (max-width: 1024px) {
.panel-moderation { grid-template-columns: 1fr; }
}
.player-wrap {
position: relative;
width: 100%;
padding-bottom: 56.25%;
height: 0;
overflow: hidden;
border-radius: 12px;
background: #0e0e10;
box-shadow: 0 4px 20px rgba(0,0,0,.25);
}
.player-wrap iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
.chat-wrap {
border-radius: 12px;
overflow: hidden;
background: #18181b;
box-shadow: 0 4px 20px rgba(0,0,0,.2);
min-height: 320px;
display: flex;
flex-direction: column;
}
.chat-wrap iframe {
flex: 1;
min-height: 280px;
border: none;
}
.mod-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: #1f1f23;
border-bottom: 1px solid #2d2d35;
}
.mod-toolbar input[type="text"] {
width: 140px;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid #3d3d48;
background: #18181b;
color: #efeff1;
font-size: 13px;
}
.mod-toolbar input::placeholder { color: #6b6b7b; }
.mod-toolbar button {
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
border: none;
cursor: pointer;
transition: background .15s, transform .05s;
}
.mod-toolbar button:active { transform: scale(0.98); }
.btn-timeout { background: #e0a82e; color: #1f1f23; }
.btn-timeout:hover { background: #e8b84a; }
.btn-ban { background: #eb0400; color: #fff; }
.btn-ban:hover { background: #ff1a15; }
.btn-unban { background: #338847; color: #fff; }
.btn-unban:hover { background: #3da352; }
.btn-clean { background: #53535f; color: #efeff1; }
.btn-clean:hover { background: #63636f; }
.btn-copy { background: #9147ff; color: #fff; }
.btn-copy:hover { background: #a970ff; }
.right-col { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
.commandes-logs {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
min-height: 0;
}
.section-card {
background: #fff;
border-radius: 12px;
border: 1px solid #e5e7eb;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,.06);
}
.dark .section-card {
background: #1f2937;
border-color: #374151;
box-shadow: 0 1px 3px rgba(0,0,0,.2);
}
.section-card-header {
padding: 10px 14px;
font-weight: 600;
font-size: 14px;
color: #111827;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.dark .section-card-header {
background: #111827;
color: #f3f4f6;
border-color: #374151;
}
.section-card-body {
max-height: 220px;
overflow: auto;
}
.section-card-body-logs {
max-height: 280px;
}
.cmd-table, .logs-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.cmd-table th, .logs-table th {
text-align: left;
padding: 8px 12px;
background: #f3f4f6;
color: #4b5563;
font-weight: 600;
}
.dark .cmd-table th, .dark .logs-table th { background: #374151; color: #9ca3af; }
.cmd-table td, .logs-table td { padding: 6px 12px; border-bottom: 1px solid #e5e7eb; color: #374151; }
.dark .cmd-table td, .dark .logs-table td { border-color: #374151; color: #e5e7eb; }
.cmd-table tr:hover, .logs-table tr:hover { background: #f9fafb; }
.dark .cmd-table tr:hover, .dark .logs-table tr:hover { background: #374151; }
.page-title-mod { font-size: 1.25rem; margin-bottom: 0.5rem; }
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
</style>
<div class="space-y-4">
<div>
<h1 class="page-title-mod font-bold text-gray-900 dark:text-white">Modération Twitch</h1>
<p class="text-sm text-gray-600 dark:text-gray-400">Commandes et logs de modération pour {{ twitch_channel }}</p>
</div>
<!-- Info bricks: Live status, Link filter, Banned words -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Live Status -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg {{ 'bg-red-100 dark:bg-red-900/30' if is_live else 'bg-gray-100 dark:bg-gray-700' }}">
<svg class="w-5 h-5 {{ 'text-red-600 dark:text-red-400' if is_live else 'text-gray-500' }}" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="10" r="8"/>
</svg>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white text-sm">Live</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ 'En ligne' if is_live else 'Hors ligne' }}</p>
</div>
</div>
{% if is_live %}
<div class="text-right">
<div class="text-lg font-bold text-purple-600 dark:text-purple-400">{{ viewer_count }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">viewers</div>
</div>
{% endif %}
</div>
</div>
<!-- Link Filter -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg {{ 'bg-green-100 dark:bg-green-900/30' if link_filter_enabled else 'bg-gray-100 dark:bg-gray-700' }}">
<svg class="w-5 h-5 {{ 'text-green-600 dark:text-green-400' if link_filter_enabled else 'text-gray-500' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white text-sm">Filtre de liens</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ 'Actif' if link_filter_enabled else 'Inactif' }}</p>
</div>
</div>
<a href="{{ url_for('link_filter') }}" class="text-xs text-purple-600 dark:text-purple-400 hover:underline">Configurer</a>
</div>
</div>
<!-- Banned Words -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg {{ 'bg-orange-100 dark:bg-orange-900/30' if banned_words else 'bg-gray-100 dark:bg-gray-700' }}">
<svg class="w-5 h-5 {{ 'text-orange-600 dark:text-orange-400' if banned_words else 'text-gray-500' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
</svg>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white text-sm">Mots interdits</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ banned_words|length }} mot{{ 's' if banned_words|length > 1 else '' }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Commandes & logs Twitch (sans lecteur/iframe) -->
<div class="grid md:grid-cols-2 gap-4">
<!-- Commandes de modération -->
<div class="section-card">
<div class="section-card-header flex items-center justify-between">
<span>Commandes de modération</span>
<input type="text" id="searchInput" placeholder="Rechercher..." class="w-36 px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
</div>
<div class="section-card-body">
<table class="cmd-table" id="commandsTable">
<thead>
<tr>
<th>Commande(s)</th>
<th>Usage</th>
<th>Permission</th>
</tr>
</thead>
<tbody id="commandsBody">
{% for cmd in commands %}
<tr class="command-row" data-search="{{ cmd.commands|join(' ') }} {{ cmd.usage }} {{ cmd.description }}">
<td>
{% for c in cmd.commands %}
<code class="px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded text-xs font-mono">{{ c }}</code>
{% if not loop.last %} {% endif %}
{% endfor %}
</td>
<td><code class="text-xs font-mono text-gray-600 dark:text-gray-400">{{ cmd.usage }}</code></td>
<td><span class="text-xs text-gray-500 dark:text-gray-400">{{ cmd.permission }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Logs de modération -->
<div class="section-card">
<div class="section-card-header flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<span>Logs de modération (<span id="logsCount">{{ logs|length }}</span>)</span>
<div class="flex items-center gap-2">
<input type="text" id="logsSearchInput" placeholder="Rechercher..." class="w-32 px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
{% if logs %}
<a href="{{ url_for('clear_twitch_logs') }}" onclick="return confirm('Effacer tous les logs ?')" class="text-xs text-red-600 hover:text-red-700">Effacer</a>
{% endif %}
</div>
</div>
<div class="section-card-body">
{% if logs %}
<table class="logs-table" id="logsTable">
<thead>
<tr>
<th>Date</th>
<th>Action</th>
<th>Modo</th>
<th>Cible</th>
<th>Détails</th>
</tr>
</thead>
<tbody id="logsBody">
{% for log in logs %}
<tr class="log-row" data-search="{{ log.action }} {{ log.moderator }} {{ log.target or '' }} {{ log.details or '' }}">
<td class="whitespace-nowrap text-gray-500 dark:text-gray-400">{{ log.created_at.strftime('%d/%m %H:%M') }}</td>
<td><code class="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs">{{ log.action }}</code></td>
<td>{{ log.moderator }}</td>
<td>{{ log.target or '-' }}</td>
<td class="max-w-[120px] truncate" title="{{ log.details or '' }}">{{ log.details or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400">Aucun log</div>
{% endif %}
</div>
</div>
</div>
<!-- Lecteur Live + Chat Twitch (si live en cours) OU Formulaire + Chat (hors live) -->
<div class="mt-6">
{% if is_live %}
<!-- Layout avec lecteur vidéo (live en cours) -->
<div class="grid lg:grid-cols-3 gap-6">
<!-- Lecteur Twitch (2/3) -->
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="aspect-video bg-gray-900">
<iframe
src="https://player.twitch.tv/?channel={{ twitch_channel }}&enableExtensions=true&muted=false&parent={{ embed_parent }}&player=popout&quality=auto&volume=0.5"
style="width: 100%; height: 100%; border: none;"
allowfullscreen>
</iframe>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="flex h-2 w-2 relative">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
<span class="text-sm font-medium text-gray-900 dark:text-white">EN DIRECT</span>
<span class="text-sm text-gray-500 dark:text-gray-400">• {{ viewer_count }} viewers</span>
</div>
<a href="https://www.twitch.tv/{{ twitch_channel }}" target="_blank" class="text-xs text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
Ouvrir sur Twitch
</a>
</div>
</div>
<!-- Formulaire rapide (1/3) -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<h2 class="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Nouvelle commande
</h2>
<form action="{{ url_for('add_twitch_commande') }}" method="POST" class="space-y-3">
<input type="text" name="trigger" placeholder="!commande" required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
<select name="twitch_permission"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
{% for value, label in twitch_permissions.items() %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
<textarea name="response" rows="3" placeholder="Réponse... ({user} = mention)" required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm resize-none"></textarea>
<button type="submit" class="w-full px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium text-sm">Ajouter</button>
</form>
</div>
</div>
<!-- Chat en dessous du lecteur -->
<div class="mt-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col" style="height: 500px;">
{% else %}
<!-- Layout sans lecteur (hors live) -->
<div class="grid md:grid-cols-2 gap-6">
<!-- Formulaire d'ajout de commande -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Ajouter une commande
</h2>
<form action="{{ url_for('add_twitch_commande') }}" method="POST" class="space-y-3">
<div class="grid grid-cols-2 gap-2">
<input type="text" name="trigger" placeholder="!commande" required
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
<select name="twitch_permission"
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
{% for value, label in twitch_permissions.items() %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<textarea name="response" rows="2" placeholder="Réponse du bot... ({user} = mention)" required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm resize-none"></textarea>
<button type="submit" class="w-full px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium text-sm">Ajouter</button>
</form>
</div>
<!-- Chat -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col" style="height: 500px;">
{% endif %}
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center justify-between">
<div class="flex items-center gap-2">
<h3 class="font-semibold text-gray-900 dark:text-white">Chat en direct</h3>
<span class="flex h-2 w-2 relative">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
</div>
<a href="https://www.twitch.tv/{{ twitch_channel }}/chat" target="_blank" class="text-xs text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
Ouvrir sur Twitch
</a>
</div>
<!-- Zone d'affichage des messages et actions -->
<div class="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900/50 p-4" id="chatDisplay">
<div class="space-y-2">
<div class="text-center text-sm text-gray-500 dark:text-gray-400 py-8">
<div class="animate-pulse mb-4">
<svg class="w-12 h-12 mx-auto mb-3 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
</svg>
</div>
<p class="font-medium text-gray-700 dark:text-gray-300">Récupération du chat...</p>
<p class="text-xs mt-1">Les messages du chat Twitch apparaîtront ici en temps réel</p>
</div>
</div>
</div>
<!-- Zone de saisie et boutons -->
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 border-t border-gray-200 dark:border-gray-600">
<!-- Boutons d'action rapide -->
<div class="grid grid-cols-4 gap-1.5 mb-3">
<!-- Ligne 1 : Actions sur les users -->
<button onclick="executeQuickAction('ban')" class="px-2 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Bannir un utilisateur">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 75.636 5.636m12.728 12.728L5.636 5.636"></path>
</svg>
Ban
</button>
<button onclick="executeQuickAction('timeout')" class="px-2 py-1.5 bg-orange-600 hover:bg-orange-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Timeout un utilisateur">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Timeout
</button>
<button onclick="executeQuickAction('clean')" class="px-2 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Nettoyer le chat">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Clean
</button>
<button onclick="executeQuickAction('permit')" class="px-2 py-1.5 bg-teal-600 hover:bg-teal-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Permettre un lien">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
Permit
</button>
<!-- Ligne 2 : Modes du chat -->
<button onclick="executeQuickAction('subon')" class="px-2 py-1.5 bg-purple-600 hover:bg-purple-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Activer mode abonnés">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
Sub ON
</button>
<button onclick="executeQuickAction('suboff')" class="px-2 py-1.5 bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Désactiver mode abonnés">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"></path>
</svg>
Sub OFF
</button>
<button onclick="executeQuickAction('emoteon')" class="px-2 py-1.5 bg-yellow-600 hover:bg-yellow-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Activer mode emotes">
😀 ON
</button>
<button onclick="executeQuickAction('emoteoff')" class="px-2 py-1.5 bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Désactiver mode emotes">
😀 OFF
</button>
</div>
<!-- Input de message -->
<form onsubmit="sendMessage(event)" class="flex gap-2">
<input
type="text"
id="chatMessage"
placeholder="Message ou commande..."
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm"
autocomplete="off">
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium text-sm">
Envoyer
</button>
</form>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Envoyé via le bot • <a href="https://www.twitch.tv/popout/{{ twitch_channel }}/chat" target="_blank" class="text-purple-600 dark:text-purple-400 hover:underline">Ouvrir le chat</a></p>
</div>
</div>
</div>
</div> <!-- Fermeture du conteneur principal -->
{% if custom_commands %}
<div class="mt-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<h3 class="font-semibold text-gray-900 dark:text-white">Commandes personnalisées Twitch ({{ custom_commands|length }})</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Commande</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Réponse</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Permission</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for cmd in custom_commands %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-2"><code class="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded text-xs">{{ cmd.trigger }}</code></td>
<td class="px-4 py-2 text-gray-700 dark:text-gray-300 max-w-xs truncate">{{ cmd.response }}</td>
<td class="px-4 py-2">{{ twitch_permissions.get(cmd.twitch_permission or 'viewer', 'Tous') }}</td>
<td class="px-4 py-2"><a href="{{ url_for('delete_commande', commande_id=cmd.id) }}" onclick="return confirm('Supprimer ?')" class="text-red-600 hover:text-red-700 text-xs">Supprimer</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- Section mots interdits -->
<div class="mt-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<h3 class="font-semibold text-gray-900 dark:text-white">Mots interdits ({{ banned_words|length }})</h3>
</div>
<!-- Formulaire d'ajout -->
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<form action="{{ url_for('add_banned_word') }}" method="POST" class="flex flex-wrap gap-2 items-end">
<div class="flex-1 min-w-[200px]">
<label for="banned_word" class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Mot interdit</label>
<input type="text" name="word" id="banned_word" placeholder="mot" required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
</div>
<div class="w-32">
<label for="banned_word_timeout" class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Timeout (s)</label>
<input type="number" name="timeout_duration" id="banned_word_timeout" value="60" min="0" max="1209600"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
</div>
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg transition-colors font-medium text-sm">Ajouter</button>
</form>
</div>
<!-- Liste des mots interdits -->
{% if banned_words %}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Mot</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Timeout</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Ajouté le</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for word in banned_words %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-2"><code class="px-2 py-1 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded text-xs font-mono">{{ word.word }}</code></td>
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">{{ word.timeout_duration }}s</td>
<td class="px-4 py-2 text-gray-500 dark:text-gray-400 text-xs">{{ word.created_at.strftime('%d/%m/%Y %H:%M') if word.created_at else '-' }}</td>
<td class="px-4 py-2"><a href="{{ url_for('delete_banned_word', word_id=word.id) }}" onclick="return confirm('Supprimer ce mot interdit ?')" class="text-red-600 hover:text-red-700 text-xs">Supprimer</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400">Aucun mot interdit configuré</div>
{% endif %}
</div>
</div>
<script>
(function() {
var modTarget = document.getElementById('modTarget');
var copyCmdBtn = document.getElementById('copyCmdBtn');
var lastCmd = '';
document.querySelectorAll('.mod-toolbar button[data-cmd]').forEach(function(btn) {
btn.addEventListener('click', function() {
var user = (modTarget && modTarget.value.trim()) || '';
lastCmd = this.getAttribute('data-cmd').replace(/\{\}/g, user).trim();
if (user && (this.classList.contains('btn-clean') && lastCmd === '!clean')) {
lastCmd = '!clean';
}
navigator.clipboard && navigator.clipboard.writeText(lastCmd).then(function() {
var t = copyCmdBtn.textContent;
copyCmdBtn.textContent = 'Copié !';
setTimeout(function() { copyCmdBtn.textContent = t; }, 1200);
});
});
});
if (copyCmdBtn) {
copyCmdBtn.addEventListener('click', function() {
if (lastCmd) {
navigator.clipboard && navigator.clipboard.writeText(lastCmd).then(function() {
var t = copyCmdBtn.textContent;
copyCmdBtn.textContent = 'Copié !';
setTimeout(function() { copyCmdBtn.textContent = t; }, 1200);
});
}
});
}
var rows = document.querySelectorAll('.command-row');
var searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.addEventListener('input', function() {
var q = this.value.toLowerCase();
rows.forEach(function(row) {
row.style.display = row.dataset.search.toLowerCase().indexOf(q) >= 0 ? '' : 'none';
});
});
}
var logRows = document.querySelectorAll('.log-row');
var logsSearchInput = document.getElementById('logsSearchInput');
var logsCount = document.getElementById('logsCount');
if (logsSearchInput) {
logsSearchInput.addEventListener('input', function() {
var q = this.value.toLowerCase();
var n = 0;
logRows.forEach(function(row) {
var ok = row.dataset.search.toLowerCase().indexOf(q) >= 0;
row.style.display = ok ? '' : 'none';
if (ok) n++;
});
if (logsCount) logsCount.textContent = n;
});
}
})();
// Fonctions pour le chat Twitch
var displayedMessages = new Set();
var isAutoScroll = true;
function addMessageToDisplay(username, message, timestamp, badges, userColor) {
var chatDisplay = document.getElementById('chatDisplay');
var messagesContainer = chatDisplay.querySelector('.space-y-2');
// Supprimer le message d'aide si c'est le premier message
var helpText = messagesContainer.querySelector('.text-center');
if (helpText) {
helpText.remove();
}
// Créer un ID unique pour ce message
var messageId = timestamp + username + message;
if (displayedMessages.has(messageId)) {
return; // Message déjà affiché
}
displayedMessages.add(messageId);
// Créer l'élément de message
var messageDiv = document.createElement('div');
var msgDate = new Date(timestamp);
var timeStr = msgDate.getHours().toString().padStart(2, '0') + ':' + msgDate.getMinutes().toString().padStart(2, '0');
// Déterminer les badges
var badgeIcons = '';
if (badges && badges.is_mod) {
badgeIcons += '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400" title="Modérateur">MOD</span>';
}
if (badges && badges.is_vip) {
badgeIcons += '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-400" title="VIP">VIP</span>';
}
if (badges && badges.is_subscriber) {
badgeIcons += '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400" title="Abonné">SUB</span>';
}
messageDiv.className = 'bg-white dark:bg-gray-800 rounded-lg p-3 text-sm animate-fade-in border-l-2 border-gray-300 dark:border-gray-600';
messageDiv.style.borderLeftColor = userColor || '#9146FF';
messageDiv.innerHTML = `
<div class="flex items-start gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<span class="font-bold" style="color: ${userColor || '#9146FF'}">${escapeHtml(username)}</span>
${badgeIcons}
<span class="text-xs text-gray-500 dark:text-gray-400">${timeStr}</span>
</div>
<div class="text-gray-900 dark:text-gray-100 break-words">${escapeHtml(message)}</div>
</div>
</div>
`;
messagesContainer.appendChild(messageDiv);
// Limiter à 100 messages affichés
while (messagesContainer.children.length > 100) {
messagesContainer.removeChild(messagesContainer.firstChild);
}
// Scroll vers le bas si auto-scroll activé
if (isAutoScroll) {
chatDisplay.scrollTop = chatDisplay.scrollHeight;
}
}
function addBotMessageToDisplay(message, type) {
var chatDisplay = document.getElementById('chatDisplay');
var messagesContainer = chatDisplay.querySelector('.space-y-2');
// Supprimer le message d'aide si c'est le premier message
var helpText = messagesContainer.querySelector('.text-center');
if (helpText) {
helpText.remove();
}
var now = new Date();
var timeStr = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0');
var bgColor = 'bg-blue-50 dark:bg-blue-900/20';
var borderColor = '#3b82f6';
if (type === 'command') {
bgColor = 'bg-purple-50 dark:bg-purple-900/20';
borderColor = '#9146FF';
}
var messageDiv = document.createElement('div');
messageDiv.className = `${bgColor} rounded-lg p-3 text-sm animate-fade-in border-l-2`;
messageDiv.style.borderLeftColor = borderColor;
messageDiv.innerHTML = `
<div class="flex items-start gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-bold text-purple-600 dark:text-purple-400">Bot</span>
<span class="text-xs text-gray-500 dark:text-gray-400">${timeStr}</span>
</div>
<div class="text-gray-900 dark:text-gray-100 break-words">${escapeHtml(message)}</div>
</div>
</div>
`;
messagesContainer.appendChild(messageDiv);
// Limiter à 100 messages
while (messagesContainer.children.length > 100) {
messagesContainer.removeChild(messagesContainer.firstChild);
}
// Scroll vers le bas
if (isAutoScroll) {
chatDisplay.scrollTop = chatDisplay.scrollHeight;
}
}
// Polling pour récupérer les messages
function fetchChatMessages() {
fetch('{{ url_for("get_twitch_messages") }}')
.then(response => response.json())
.then(data => {
if (data.messages && data.messages.length > 0) {
data.messages.forEach(function(msg) {
addMessageToDisplay(
msg.username,
msg.text,
msg.timestamp,
{
is_mod: msg.is_mod,
is_vip: msg.is_vip,
is_subscriber: msg.is_subscriber
},
msg.color
);
});
}
})
.catch(error => {
console.error('Erreur lors de la récupération des messages:', error);
});
}
// Démarrer le polling
setInterval(fetchChatMessages, 2000); // Toutes les 2 secondes
fetchChatMessages(); // Premier chargement immédiat
// Détecter le scroll manuel pour désactiver l'auto-scroll
document.getElementById('chatDisplay').addEventListener('scroll', function() {
var element = this;
var isAtBottom = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
isAutoScroll = isAtBottom;
});
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function sendMessage(event) {
event.preventDefault();
var input = document.getElementById('chatMessage');
var message = input.value.trim();
if (!message) return;
// Désactiver le bouton pendant l'envoi
var submitBtn = event.target.querySelector('button[type="submit"]');
var originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = 'Envoi...';
// Utiliser la fonction sendTextMessage pour les messages normaux
fetch('{{ url_for("send_twitch_message") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: message })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Ajouter le message à l'affichage
var type = message.startsWith('!') ? 'command' : 'message';
addBotMessageToDisplay(message, type);
input.value = '';
showNotification('Message envoyé', 'success');
} else {
showNotification(data.error || 'Erreur lors de l\'envoi', 'error');
}
})
.catch(error => {
showNotification('Erreur réseau', 'error');
})
.finally(function() {
submitBtn.disabled = false;
submitBtn.textContent = originalText;
});
}
function executeQuickAction(action) {
// Commandes simples sans paramètres
if (['subon', 'suboff', 'emoteon', 'emoteoff'].includes(action)) {
executeModerationAction(action, {});
return;
}
// Ban : demander utilisateur + raison
if (action === 'ban') {
var username = prompt('Nom d\'utilisateur à bannir :', '@');
if (username && username.trim() && username !== '@') {
username = username.trim().replace('@', '');
var reason = prompt('Raison (optionnelle) :', 'Violation des règles');
executeModerationAction('ban', {
username: username,
reason: reason || 'Ban'
});
}
return;
}
// Timeout : demander utilisateur + durée + raison
if (action === 'timeout') {
var username = prompt('Nom d\'utilisateur à timeout :', '@');
if (username && username.trim() && username !== '@') {
username = username.trim().replace('@', '');
var duration = prompt('Durée en minutes (défaut: 10) :', '10');
duration = parseInt(duration) || 10;
var reason = prompt('Raison (optionnelle) :', 'Timeout');
executeModerationAction('timeout', {
username: username,
duration: duration * 60, // Convertir en secondes
reason: reason || 'Timeout'
});
}
return;
}
// Clean : demander si utilisateur spécifique ou tout le chat
if (action === 'clean') {
var username = prompt('Nom d\'utilisateur (ou laissez vide pour tout le chat) :', '');
if (username === null) return; // Annulé
executeModerationAction('clean', {
username: username ? username.trim().replace('@', '') : ''
});
return;
}
// Permit : demander utilisateur + durée
if (action === 'permit') {
var username = prompt('Nom d\'utilisateur à autoriser :', '@');
if (username && username.trim() && username !== '@') {
username = username.trim().replace('@', '');
var duration = prompt('Durée en minutes (défaut: 1) :', '1');
duration = parseInt(duration) || 1;
executeModerationAction('permit', {
username: username,
duration: duration * 60 // Convertir en secondes
});
}
return;
}
}
function executeModerationAction(action, params) {
fetch('{{ url_for("execute_moderation_action") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ action: action, params: params })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(data.message || 'Action exécutée', 'success');
// Ajouter un message visuel dans le chat
var actionText = action.toUpperCase();
if (params.username) {
actionText += ' @' + params.username;
}
if (params.duration) {
actionText += ' (' + (params.duration / 60) + 'min)';
}
if (params.reason) {
actionText += ' - ' + params.reason;
}
addBotMessageToDisplay(actionText, 'command');
} else {
showNotification(data.error || 'Erreur lors de l\'exécution', 'error');
}
})
.catch(error => {
showNotification('Erreur réseau', 'error');
});
}
// Cette fonction est conservée pour les messages texte normaux (non-modération)
function sendTextMessage(message) {
fetch('{{ url_for("send_twitch_message") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: message })
})
.then(response => response.json())
.then(data => {
if (data.success) {
var type = message.startsWith('!') ? 'command' : 'message';
addBotMessageToDisplay(message, type);
showNotification('Message envoyé', 'success');
} else {
showNotification(data.error || 'Erreur lors de l\'envoi', 'error');
}
})
.catch(error => {
showNotification('Erreur réseau', 'error');
});
}
function showNotification(message, type) {
var color = type === 'success' ? 'bg-green-500' : 'bg-red-500';
var notification = document.createElement('div');
notification.className = `fixed bottom-4 right-4 ${color} text-white px-4 py-2 rounded-lg shadow-lg z-50`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(function() {
notification.remove();
}, 3000);
}
</script>
{% endblock %}
+292
View File
@@ -0,0 +1,292 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-12 h-12 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg class="w-7 h-7 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Gestion des utilisateurs</h1>
<p class="text-gray-600 dark:text-gray-400">Liste des comptes webapp et attribution des rôles</p>
</div>
</div>
<button type="button" onclick="document.getElementById('create-user-modal').classList.remove('hidden')"
class="flex-shrink-0 px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span>Créer un utilisateur</span>
</button>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-6 space-y-2">
{% for category, msg in messages %}
<div class="p-4 rounded-lg {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800{% endif %}">
{{ msg }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
{% if users %}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Utilisateur
</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
E-mail
</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Rôle actuel
</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Inscrit le
</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Modifier le rôle
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for u in users %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white font-semibold">
{{ u.username[0].upper() }}
</div>
<div>
<div class="font-medium text-gray-900 dark:text-white">{{ u.username }}</div>
{% if u.id == current_user.id %}
<span class="text-xs text-primary-600 dark:text-primary-400 font-medium">C'est vous</span>
{% endif %}
</div>
</div>
</td>
<td class="px-6 py-4">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ u.email }}</span>
</td>
<td class="px-6 py-4">
{% set user_role = None %}
{% for r in roles %}
{% if r == u.role %}
{% set user_role = r %}
{% endif %}
{% endfor %}
{% if user_role %}
{% set role_obj = namespace(found=None) %}
{% for role_name in roles %}
{% if role_name == user_role %}
{% for r_obj in roles %}
{# Obtenir l'objet WebappRole complet #}
{% endfor %}
{% endif %}
{% endfor %}
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap"
style="background-color: #6B728020; color: #6B7280;">
<span class="w-2 h-2 rounded-full" style="background-color: #6B7280;"></span>
{{ role_labels.get(u.role, u.role) }}
</span>
{% else %}
<span class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
{{ role_labels.get(u.role, u.role) }}
</span>
{% endif %}
</td>
<td class="px-6 py-4">
<span class="text-sm text-gray-500 dark:text-gray-400">
{% if u.created_at %}{{ u.created_at.strftime('%d/%m/%Y à %H:%M') }}{% else %}—{% endif %}
</span>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<form action="{{ url_for('users_set_role', user_id=u.id) }}" method="post" class="flex items-center gap-2">
<select name="role" class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 transition-colors"
{% if u.id == current_user.id %}disabled title="Vous ne pouvez pas modifier votre propre rôle"{% endif %}>
{% for r in roles %}
<option value="{{ r }}" {% if u.role == r %}selected{% endif %}>{{ role_labels.get(r, r) }}</option>
{% endfor %}
</select>
<button type="submit"
{% if u.id == current_user.id %}disabled{% endif %}
class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white text-sm font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
Appliquer
</button>
</form>
{% if u.id != current_user.id %}
<form action="{{ url_for('users_delete', user_id=u.id) }}" method="post" class="inline" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ?');">
<button type="submit" class="px-3 py-2 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm font-medium hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors" title="Supprimer cet utilisateur">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</form>
{% endif %}
</div>
{% if u.id == current_user.id %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Protection: modification/suppression impossible</p>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-12 text-center">
<svg class="w-16 h-16 mx-auto text-gray-400 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<p class="text-gray-500 dark:text-gray-400 text-lg">Aucun utilisateur enregistré</p>
</div>
{% endif %}
</div>
<!-- Légende des rôles -->
{% if users %}
<div class="mt-6 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800 p-6">
<h3 class="text-lg font-semibold text-blue-900 dark:text-blue-200 mb-4 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Hiérarchie des rôles</span>
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{% for role_name in roles %}
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 border border-blue-100 dark:border-blue-900">
<div class="flex items-center gap-2 mb-1">
<span class="w-3 h-3 rounded-full" style="background-color: #6B7280;"></span>
<span class="font-medium text-gray-900 dark:text-white text-sm">{{ role_labels.get(role_name, role_name) }}</span>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400 ml-5">
{% if role_name == 'viewer_twitch' %}
Accès minimal, consultation uniquement
{% elif role_name == 'utilisateur_discord' %}
Modification de contenu basique
{% elif role_name == 'moderateur_discord' %}
Outils de modération Discord
{% elif role_name == 'expert_discord' %}
Gestion avancée du contenu
{% elif role_name == 'moderateur_twitch' %}
Outils de modération Twitch
{% elif role_name == 'super_administrateur' %}
Accès complet au système
{% else %}
Rôle personnalisé
{% endif %}
</p>
</div>
{% endfor %}
</div>
<div class="mt-4 p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<strong>💡 Conseil :</strong> Vous pouvez personnaliser les rôles et leurs permissions dans la page
<a href="{{ url_for('settings') }}" class="underline hover:text-blue-900 dark:hover:text-blue-100">Paramètres</a>.
</p>
</div>
</div>
{% endif %}
<!-- Modal de création d'utilisateur -->
<div id="create-user-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" onclick="document.getElementById('create-user-modal').classList.add('hidden')"></div>
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<form action="{{ url_for('users_create') }}" method="post">
<div class="bg-white dark:bg-gray-800 px-6 pt-6 pb-4">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
<svg class="w-6 h-6 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Créer un utilisateur</h3>
</div>
<button type="button" onclick="document.getElementById('create-user-modal').classList.add('hidden')"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Nom d'utilisateur <span class="text-red-500">*</span>
</label>
<input type="text" id="username" name="username" required minlength="3"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
placeholder="ex: john_doe">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Adresse e-mail <span class="text-red-500">*</span>
</label>
<input type="email" id="email" name="email" required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
placeholder="ex: john@example.com">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Mot de passe <span class="text-red-500">*</span>
</label>
<input type="password" id="password" name="password" required minlength="8"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
placeholder="Au moins 8 caractères">
</div>
<div>
<label for="password_confirm" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirmer le mot de passe <span class="text-red-500">*</span>
</label>
<input type="password" id="password_confirm" name="password_confirm" required minlength="8"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
placeholder="Retapez le mot de passe">
</div>
<div>
<label for="role" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Rôle <span class="text-red-500">*</span>
</label>
<select id="role" name="role" required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
{% for r in roles %}
<option value="{{ r }}">{{ role_labels.get(r, r) }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 flex gap-3 justify-end">
<button type="button" onclick="document.getElementById('create-user-modal').classList.add('hidden')"
class="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
Annuler
</button>
<button type="submit"
class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
Créer l'utilisateur
</button>
</div>
</form>
</div>
</div>
</div>
<style>
/* Animation au survol des lignes */
tbody tr {
transition: all 0.2s ease;
}
</style>
{% endblock %}
+342
View File
@@ -0,0 +1,342 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Notifications YouTube</h1>
{% if msg %}
<div id="alert-msg" class="mb-4 p-4 rounded-lg {{ 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300' if msg_type == 'error' else 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300' }}">
{{ msg }}
</div>
<script>
setTimeout(function() {
var el = document.getElementById('alert-msg');
if (el) el.style.display = 'none';
}, 5000);
</script>
{% endif %}
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Liste des chaînes YouTube surveillées pour les notifications de nouvelles vidéos.
Le bot vérifie toutes les 5 minutes les nouvelles vidéos des chaînes en dessous.
Quand une nouvelle vidéo est détectée, le bot enverra une notification sur Discord.
</p>
</div>
</div>
{% if not notification %}
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Notifications configurées</h2>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Chaîne YouTube</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Canal Discord</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Type</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for notification in notifications %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<code class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-red-600 dark:text-red-400 font-mono text-sm">{{ notification.channel_id }}</code>
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-700 dark:text-gray-300">{{ notification.notify_channel_name }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-medium rounded-full
{% if notification.video_type == 'all' %}bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300
{% elif notification.video_type == 'video' %}bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300
{% else %}bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300{% endif %}">
{% if notification.video_type == 'all' %}Toutes
{% elif notification.video_type == 'video' %}Vidéos
{% else %}Shorts{% endif %}
</span>
</td>
<td class="px-6 py-4 text-gray-600 dark:text-gray-400 max-w-xs truncate">{{ notification.message }}</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
<a href="{{ url_for('toggleYouTube', id = notification.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
title="{{ 'Désactiver' if notification.enable else 'Activer' }}">
{{ '✅' if notification.enable else '❌' }}
</a>
<a href="{{ url_for('openEditYouTube', id = notification.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors text-blue-600 dark:text-blue-400"
title="Modifier">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
</a>
<a href="{{ url_for('delYouTube', id = notification.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette notification ?')"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400"
title="Supprimer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
</a>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
Aucune notification configurée. Ajoutez-en une ci-dessous.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
{{ 'Modifier la notification' if notification else 'Ajouter une notification YouTube' }}
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<form id="youtube-form" action="{{ url_for('submitEditYouTube', id = notification.id) if notification else url_for('addYouTube') }}" method="POST" class="space-y-6">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Configuration de base</h3>
<div>
<label for="channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Lien ou ID de la chaîne YouTube</label>
<input name="channel_id" id="channel_id" type="text" maxlength="256" required
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
placeholder="https://www.youtube.com/@513v3 ou UC..."
value="{{notification.channel_id if notification}}"/>
</div>
<div>
<label for="notify_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de notification Discord</label>
<select name="notify_channel" id="notify_channel"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all">
{% for channel in channels %}
<option value="{{channel.id}}" {% if notification and notification.notify_channel == channel.id %}selected{% endif %}>{{channel.name}}</option>
{% endfor %}
</select>
</div>
<div>
<label for="video_type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Type de vidéo à notifier</label>
<select name="video_type" id="video_type"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all">
<option value="all" {% if notification and notification.video_type == 'all' %}selected{% endif %}>Toutes (vidéos + shorts)</option>
<option value="video" {% if notification and notification.video_type == 'video' %}selected{% endif %}>Vidéos uniquement</option>
<option value="short" {% if notification and notification.video_type == 'short' %}selected{% endif %}>Shorts uniquement</option>
</select>
</div>
<div>
<label for="message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message (optionnel)</label>
<textarea name="message" id="message" rows="2"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all resize-y"
placeholder="Message envoyé avant l'embed">{{notification.message if notification}}</textarea>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Personnalisation de l'embed Discord</h3>
<div>
<label for="embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre de l'embed</label>
<input name="embed_title" id="embed_title" type="text" maxlength="256"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
placeholder="{video_title}"
value="{{notification.embed_title if notification}}"/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Variables: {video_title}, {channel_name}, {video_url}, {video_id}</p>
</div>
<div>
<label for="embed_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description de l'embed</label>
<textarea name="embed_description" id="embed_description" rows="2"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all resize-y"
placeholder="Description optionnelle">{{notification.embed_description if notification}}</textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="embed_color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Couleur</label>
<div class="flex items-center gap-2">
<input name="embed_color" id="embed_color" type="color"
class="w-12 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
value="#{{notification.embed_color if notification else 'FF0000'}}"/>
<input type="text" id="embed_color_text" maxlength="6"
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
value="{{notification.embed_color if notification else 'FF0000'}}" placeholder="FF0000"/>
</div>
</div>
<div>
<label for="embed_author_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom de l'auteur</label>
<input name="embed_author_name" id="embed_author_name" type="text" maxlength="256"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
placeholder="{channel_name}"
value="{{notification.embed_author_name if notification}}"/>
</div>
</div>
<div>
<label for="embed_author_icon" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icône de l'auteur (URL)</label>
<input name="embed_author_icon" id="embed_author_icon" type="text" maxlength="512"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
placeholder="https://www.youtube.com/img/desktop/yt_1200.png"
value="{{notification.embed_author_icon if notification}}"/>
</div>
<div>
<label for="embed_footer" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Pied de page</label>
<input name="embed_footer" id="embed_footer" type="text" maxlength="2048"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
placeholder="Texte optionnel en bas"
value="{{notification.embed_footer if notification}}"/>
</div>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="embed_thumbnail" id="embed_thumbnail"
{% if not notification or notification.embed_thumbnail %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-red-600 focus:ring-red-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Miniature</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="embed_image" id="embed_image"
{% if not notification or notification.embed_image %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-red-600 focus:ring-red-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Image principale</span>
</label>
</div>
</div>
<div class="flex items-center gap-4">
<button type="submit"
class="px-6 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
{{ 'Enregistrer' if notification else 'Ajouter la notification' }}
</button>
{% if notification %}
<a href="{{ url_for('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>
</div>
</div>
<script>
function formatText(text, vars) {
if (!text) return '';
return text.replace(/\{(\w+)\}/g, function(match, key) {
return vars[key] || match;
});
}
function updatePreview() {
const embedTitle = document.getElementById('embed_title').value || '{video_title}';
const embedDescription = document.getElementById('embed_description').value || '';
const embedColor = document.getElementById('embed_color_text').value || 'FF0000';
const embedAuthorName = document.getElementById('embed_author_name').value || '{channel_name}';
const embedAuthorIcon = document.getElementById('embed_author_icon').value || 'https://www.youtube.com/img/desktop/yt_1200.png';
const embedFooter = document.getElementById('embed_footer').value || '';
const embedThumbnail = document.getElementById('embed_thumbnail').checked;
const embedImage = document.getElementById('embed_image').checked;
const vars = {
video_title: 'Nouvelle vidéo de test',
channel_name: 'Ma Chaîne YouTube',
video_url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
video_id: 'dQw4w9WgXcQ',
thumbnail: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg',
published_at: '2026-01-25T12:00:00Z',
is_short: false
};
document.getElementById('embed-title').textContent = formatText(embedTitle, vars);
document.getElementById('embed-title').href = vars.video_url;
document.getElementById('embed-description').textContent = formatText(embedDescription, vars);
document.getElementById('embed-author-name').textContent = formatText(embedAuthorName, vars);
document.getElementById('embed-author-icon').src = embedAuthorIcon;
document.getElementById('embed-footer').textContent = formatText(embedFooter, vars);
document.getElementById('embed-preview').style.borderLeftColor = '#' + embedColor;
if (embedThumbnail) {
document.getElementById('embed-thumbnail').src = vars.thumbnail;
document.getElementById('embed-thumbnail').style.display = 'block';
} else {
document.getElementById('embed-thumbnail').style.display = 'none';
}
if (embedImage) {
document.getElementById('embed-image').src = vars.thumbnail;
document.getElementById('embed-image').style.display = 'block';
} else {
document.getElementById('embed-image').style.display = 'none';
}
}
document.getElementById('embed_color').addEventListener('input', function(e) {
document.getElementById('embed_color_text').value = e.target.value.substring(1).toUpperCase();
updatePreview();
});
document.getElementById('embed_color_text').addEventListener('input', function(e) {
const val = e.target.value.replace(/[^0-9A-Fa-f]/g, '').substring(0, 6);
e.target.value = val;
if (val.length === 6) {
document.getElementById('embed_color').value = '#' + val;
updatePreview();
}
});
const formFields = ['embed_title', 'embed_description', 'embed_author_name', 'embed_author_icon', 'embed_footer', 'embed_thumbnail', 'embed_image'];
formFields.forEach(field => {
const el = document.getElementById(field);
if (el) {
el.addEventListener('input', updatePreview);
el.addEventListener('change', updatePreview);
}
});
updatePreview();
</script>
{% endblock %}
+38 -17
View File
@@ -1,50 +1,71 @@
import asyncio
import logging
from flask import render_template, request, redirect, url_for
from flask import render_template, request, redirect, url_for, flash
from twitchAPI.twitch import Twitch
from twitchAPI.type import TwitchAPIException
from twitchAPI.type import TwitchAPIException
from twitchAPI.oauth import UserAuthenticator
from database import db
from database.helpers import ConfigurationHelper
from twitchbot import USER_SCOPE
from webapp import webapp
from webapp.auth import require_page
auth: UserAuthenticator
@webapp.route("/configurations/twitch/help")
def twitchConfigurationHelp():
return render_template("twitch-aide.html", token_redirect_url = _buildUrl())
@webapp.route("/configurations/twitch/request-token")
async def twitchRequestToken():
@webapp.route("/configurations/twitch/help")
@require_page("configurations")
def twitchConfigurationHelp():
return render_template("twitch-aide.html", token_redirect_url=_buildUrl())
@webapp.route("/configurations/twitch/request-token")
@require_page("configurations")
def twitchRequestToken():
global auth
helper = ConfigurationHelper()
twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
twitch = 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():
@webapp.route("/configurations/twitch/receive-token")
def twitchReceiveToken():
global auth
state = request.args.get('state')
code = request.args.get('code')
if state != auth.state :
logging('bad returned state')
logging.info("Callback Twitch reçu - state: %s, code: %s", state, code is not None)
if not hasattr(auth, 'state') or auth is None:
logging.error('Objet auth non initialisé - veuillez réessayer')
return redirect(url_for('openConfigurations'))
if code == None :
logging('no returned state')
if state != auth.state:
logging.error('State invalide - attendu: %s, reçu: %s', auth.state, state)
return redirect(url_for('openConfigurations'))
if code is None:
logging.error('Pas de code retourné par Twitch')
return redirect(url_for('openConfigurations'))
try:
token, refresh = await auth.authenticate(user_token=code)
token, refresh = asyncio.run(auth.authenticate(user_token=code))
logging.info('Tokens Twitch obtenus avec succès')
helper = ConfigurationHelper()
helper.createOrUpdate('twitch_access_token', token)
helper.createOrUpdate('twitch_refresh_token', refresh)
db.session.commit()
logging.info('Tokens Twitch sauvegardés en base de données')
flash('Token Twitch enregistré. Redémarrez l\'application pour que le bot utilise le nouveau token.', 'success')
except TwitchAPIException as e:
logging(e)
logging.error('Erreur API Twitch: %s', e)
flash(f'Erreur API Twitch : {e}', 'error')
except Exception as e:
logging.error('Erreur inattendue: %s', e)
flash(f'Erreur inattendue : {e}', 'error')
return redirect(url_for('openConfigurations'))
# hack pas fou mais on estime qu'on sera toujours en ssl en connecté
+81
View File
@@ -0,0 +1,81 @@
# Notifications d'événements Twitch : sub, follow, raid, clip (chat + Discord)
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import TwitchEventNotification
from discordbot import bot
EVENT_LABELS = {
"sub": "Abonnement (sub)",
"follow": "Nouveau follow",
"raid": "Raid reçu",
"clip": "Nouveau clip",
}
@webapp.route("/twitch-events")
@require_page("twitch_events")
def open_twitch_events():
configs = TwitchEventNotification.query.order_by(TwitchEventNotification.event_type).all()
# S'assurer qu'il existe une config par type
existing = {c.event_type for c in configs}
for ev in ("sub", "follow", "raid", "clip"):
if ev not in existing:
cfg = TwitchEventNotification(
event_type=ev,
message_twitch="Merci {user} !" if ev != "raid" else "Bienvenue aux viewers de {from_broadcaster_name} !",
)
db.session.add(cfg)
configs.append(cfg)
db.session.commit()
channels = bot.getAllTextChannel()
# Nom du canal Discord pour l'affichage
for c in configs:
if c.discord_channel_id:
c.discord_channel_name = next((ch.name for ch in channels if ch.id == c.discord_channel_id), None)
else:
c.discord_channel_name = None
return render_template("twitch-events.html", configs=configs, channels=channels, labels=EVENT_LABELS)
@webapp.route("/twitch-events/save", methods=["POST"])
@require_page("twitch_events")
def save_twitch_events():
if not can_write_page("twitch_events"):
return render_template("403.html"), 403
for ev in ("sub", "follow", "raid", "clip"):
cfg = TwitchEventNotification.query.filter_by(event_type=ev).first()
if not cfg:
cfg = TwitchEventNotification(event_type=ev)
db.session.add(cfg)
prefix = f"ev_{ev}_"
cfg.enable = request.form.get(prefix + "enable") == "1"
cfg.notify_twitch_chat = request.form.get(prefix + "notify_twitch_chat") == "1"
cfg.notify_discord = request.form.get(prefix + "notify_discord") == "1"
ch_id = request.form.get(prefix + "discord_channel_id")
cfg.discord_channel_id = int(ch_id) if ch_id and ch_id.isdigit() else None
cfg.message_twitch = (request.form.get(prefix + "message_twitch") or "").strip()[:500]
cfg.message_discord = (request.form.get(prefix + "message_discord") or "").strip()[:2000] or None
embed_color = (request.form.get(prefix + "embed_color") or "9146FF").strip().lstrip("#")[:6]
cfg.embed_color = embed_color if len(embed_color) == 6 else "9146FF"
cfg.embed_title = (request.form.get(prefix + "embed_title") or "").strip()[:256] or None
cfg.embed_description = (request.form.get(prefix + "embed_description") or "").strip()[:2000] or None
cfg.embed_thumbnail = request.form.get(prefix + "embed_thumbnail") == "1"
db.session.commit()
return redirect(url_for("open_twitch_events"))
@webapp.route("/twitch-events/toggle/<event_type>")
@require_page("twitch_events")
def toggle_twitch_event(event_type):
if not can_write_page("twitch_events"):
return render_template("403.html"), 403
if event_type not in ("sub", "follow", "raid", "clip"):
return redirect(url_for("open_twitch_events"))
cfg = TwitchEventNotification.query.filter_by(event_type=event_type).first()
if cfg:
cfg.enable = not cfg.enable
db.session.commit()
return redirect(url_for("open_twitch_events"))
+403
View File
@@ -0,0 +1,403 @@
from flask import render_template, request, redirect, url_for, jsonify
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import Commande, TwitchModerationLog, TwitchLinkFilter, TwitchBannedWord
from database.helpers import ConfigurationHelper
from datetime import datetime, timedelta
import asyncio
MODERATION_COMMANDS = [
{
"commands": ["!kick", "!to", "!timeout", "!tm"],
"usage": "!timeout <viewer> [minutes] [raison]",
"description": "Ejection temporaire d'un viewer (3 minutes par defaut) avec raison optionnelle",
"permission": "Moderateur"
},
{
"commands": ["!ban"],
"usage": "!ban <viewer1> [viewer2] ...",
"description": "Bannissement d'un ou plusieurs viewers (max 5)",
"permission": "Moderateur"
},
{
"commands": ["!unban"],
"usage": "!unban <viewer1> [viewer2] ...",
"description": "Debannissement d'un ou plusieurs viewers (max 5)",
"permission": "Moderateur"
},
{
"commands": ["!clean"],
"usage": "!clean [viewer]",
"description": "Nettoyage du chat ou des messages d'un viewer",
"permission": "Moderateur"
},
{
"commands": ["!shieldmode"],
"usage": "!shieldmode <on/off>",
"description": "Active/desactive le mode Shield de Twitch",
"permission": "Moderateur"
},
{
"commands": ["!settitle"],
"usage": "!settitle <titre>",
"description": "Changement du titre du live",
"permission": "Moderateur"
},
{
"commands": ["!setgame", "!setcateg"],
"usage": "!setgame <jeu>",
"description": "Changement du jeu/categorie du live",
"permission": "Moderateur"
},
{
"commands": ["!subon"],
"usage": "!subon",
"description": "Activation du mode abonnes uniquement",
"permission": "Moderateur"
},
{
"commands": ["!suboff"],
"usage": "!suboff",
"description": "Desactivation du mode abonnes uniquement",
"permission": "Moderateur"
},
{
"commands": ["!follon"],
"usage": "!follon [minutes]",
"description": "Activation du mode followers-only",
"permission": "Moderateur"
},
{
"commands": ["!folloff"],
"usage": "!folloff",
"description": "Desactivation du mode followers-only",
"permission": "Moderateur"
},
{
"commands": ["!emoteon"],
"usage": "!emoteon",
"description": "Activation du mode emote-only",
"permission": "Moderateur"
},
{
"commands": ["!emoteoff"],
"usage": "!emoteoff",
"description": "Desactivation du mode emote-only",
"permission": "Moderateur"
},
{
"commands": ["!ann"],
"usage": "!ann <alias> <on/off/toggle>",
"description": "Activer/desactiver/inverser une liste d'annonce par alias",
"permission": "Moderateur"
},
{
"commands": ["!no_game"],
"usage": "!no_game <on/off>",
"description": "Desactiver/activer tous les jeux de la chaine",
"permission": "Moderateur"
},
{
"commands": ["!multitwitch"],
"usage": "!multitwitch [live1] [live2] ... | auto | reset",
"description": "Creation d'un lien MultiTwitch. '@' = chaine actuelle, 'auto' = depuis le titre, 'reset' = reinitialiser",
"permission": "Moderateur (creation) / Tous (affichage)"
},
{
"commands": ["!permit"],
"usage": "!permit <viewer> [minutes]",
"description": "Autorise temporairement un viewer a poster un lien (1 minute par defaut)",
"permission": "Moderateur"
},
]
TWITCH_PERMISSIONS = {'viewer': 'Tous (viewers)', 'sub': 'Abonnés', 'vip': 'VIP', 'moderator': 'Modérateur'}
@webapp.route("/twitch-moderation")
@require_page("twitch_moderation")
def twitch_moderation():
custom_commands = Commande.query.filter_by(twitch_enable=True).all()
logs = TwitchModerationLog.query.order_by(TwitchModerationLog.created_at.desc()).limit(50).all()
raw_channel = ConfigurationHelper().getValue("twitch_channel") or webapp.config["BOT_STATUS"].get("twitch_channel_name") or "chainesteve"
twitch_channel = (raw_channel or "").strip().lower() or "chainesteve"
embed_parent = request.host or "localhost"
# Link filter status
link_filter_config = TwitchLinkFilter.query.first()
link_filter_enabled = link_filter_config.enabled if link_filter_config else False
# Banned words
banned_words = TwitchBannedWord.query.filter_by(enabled=True).all()
# Live status (from BOT_STATUS)
bot_status = webapp.config.get("BOT_STATUS", {})
is_live = bot_status.get("twitch_is_live", False)
viewer_count = bot_status.get("twitch_viewer_count", 0)
return render_template(
"twitch-moderation.html",
commands=MODERATION_COMMANDS,
custom_commands=custom_commands,
logs=logs,
twitch_permissions=TWITCH_PERMISSIONS,
twitch_channel=twitch_channel,
embed_parent=embed_parent,
link_filter_enabled=link_filter_enabled,
banned_words=banned_words,
is_live=is_live,
viewer_count=viewer_count,
)
@webapp.route("/twitch-moderation/logs/clear")
@require_page("twitch_moderation")
def clear_twitch_logs():
if not can_write_page("twitch_moderation"):
return render_template("403.html"), 403
TwitchModerationLog.query.delete()
db.session.commit()
return redirect(url_for('twitch_moderation'))
@webapp.route("/twitch-moderation/add", methods=['POST'])
@require_page("twitch_moderation")
def add_twitch_commande():
if not can_write_page("twitch_moderation"):
return render_template("403.html"), 403
trigger = request.form.get('trigger')
response = request.form.get('response')
twitch_permission = request.form.get('twitch_permission') or 'viewer'
if twitch_permission not in TWITCH_PERMISSIONS:
twitch_permission = 'viewer'
if trigger and response:
if not trigger.startswith('!'):
trigger = '!' + trigger
existing = Commande.query.filter_by(trigger=trigger).first()
if not existing:
commande = Commande(trigger=trigger, response=response, discord_enable=False, twitch_enable=True, twitch_permission=twitch_permission)
db.session.add(commande)
db.session.commit()
return redirect(url_for('twitch_moderation'))
@webapp.route("/twitch-moderation/banned-word/add", methods=['POST'])
@require_page("twitch_moderation")
def add_banned_word():
if not can_write_page("twitch_moderation"):
return render_template("403.html"), 403
word = request.form.get('word', '').strip().lower()
timeout_duration = int(request.form.get('timeout_duration', 60))
if word:
existing = TwitchBannedWord.query.filter_by(word=word).first()
if not existing:
banned_word = TwitchBannedWord(word=word, enabled=True, timeout_duration=timeout_duration)
db.session.add(banned_word)
db.session.commit()
return redirect(url_for('twitch_moderation'))
@webapp.route("/twitch-moderation/banned-word/delete/<int:word_id>")
@require_page("twitch_moderation")
def delete_banned_word(word_id):
if not can_write_page("twitch_moderation"):
return render_template("403.html"), 403
banned_word = TwitchBannedWord.query.get_or_404(word_id)
db.session.delete(banned_word)
db.session.commit()
return redirect(url_for('twitch_moderation'))
@webapp.route("/twitch-moderation/send-message", methods=['POST'])
@require_page("twitch_moderation")
def send_twitch_message():
if not can_write_page("twitch_moderation"):
return jsonify({"success": False, "error": "Permission refusée"}), 403
data = request.get_json()
message = data.get('message', '').strip()
if not message:
return jsonify({"success": False, "error": "Message vide"}), 400
# Vérifier que le bot Twitch est connecté
from twitchbot import twitchBot
if not hasattr(twitchBot, 'chat') or not twitchBot.chat:
return jsonify({"success": False, "error": "Bot Twitch non connecté"}), 503
# Récupérer le nom du channel
channel = ConfigurationHelper().getValue('twitch_channel')
if not channel:
return jsonify({"success": False, "error": "Channel Twitch non configuré"}), 400
# Envoyer le message de manière asynchrone
try:
async def send_msg():
try:
await twitchBot.chat.send_message(channel, message)
return True
except Exception as e:
return str(e)
# Exécuter la coroutine de manière synchrone
loop = asyncio.new_event_loop()
result = loop.run_until_complete(send_msg())
loop.close()
if result is True:
return jsonify({"success": True})
else:
return jsonify({"success": False, "error": f"Erreur: {result}"}), 500
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@webapp.route("/twitch-moderation/messages")
@require_page("twitch_moderation")
def get_twitch_messages():
"""Retourne les derniers messages du chat Twitch"""
messages = webapp.config["BOT_STATUS"].get("twitch_chat_messages", [])
return jsonify({"messages": messages})
@webapp.route("/twitch-moderation/execute-action", methods=['POST'])
@require_page("twitch_moderation")
def execute_moderation_action():
"""Exécute une action de modération directement"""
if not can_write_page("twitch_moderation"):
return jsonify({"success": False, "error": "Permission refusée"}), 403
data = request.get_json()
action = data.get('action', '').strip()
params = data.get('params', {})
if not action:
return jsonify({"success": False, "error": "Action non spécifiée"}), 400
# Vérifier que le bot Twitch est connecté
from twitchbot import twitchBot
if not hasattr(twitchBot, 'chat') or not twitchBot.chat or not hasattr(twitchBot, 'twitch'):
return jsonify({"success": False, "error": "Bot Twitch non connecté"}), 503
# Récupérer le nom du channel
channel = ConfigurationHelper().getValue('twitch_channel')
if not channel:
return jsonify({"success": False, "error": "Channel Twitch non configuré"}), 400
# Créer un objet ChatMessage simulé pour les commandes qui en ont besoin
from twitchAPI.chat import ChatMessage
from types import SimpleNamespace
# Exécuter l'action de manière asynchrone
try:
async def execute_action():
try:
if action == 'timeout':
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
username = params.get('username', '').strip().lstrip('@')
duration = int(params.get('duration', 600)) # en secondes
reason = params.get('reason', 'Timeout')
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
moderator_id = await _get_moderator_id(twitchBot.twitch)
user_id = await _get_user_id(twitchBot.twitch, username)
if user_id:
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason, duration=duration)
_log_action("timeout", "WebApp", username, f"{duration}s - {reason}")
return {"success": True, "message": f"Timeout de {username} pour {duration}s"}
return {"success": False, "error": f"Utilisateur {username} introuvable"}
elif action == 'ban':
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
username = params.get('username', '').strip().lstrip('@')
reason = params.get('reason', 'Ban')
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
moderator_id = await _get_moderator_id(twitchBot.twitch)
user_id = await _get_user_id(twitchBot.twitch, username)
if user_id:
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason)
_log_action("ban", "WebApp", username, reason)
return {"success": True, "message": f"Ban de {username}"}
return {"success": False, "error": f"Utilisateur {username} introuvable"}
elif action == 'clean':
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
username = params.get('username', '').strip().lstrip('@')
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
moderator_id = await _get_moderator_id(twitchBot.twitch)
if username:
user_id = await _get_user_id(twitchBot.twitch, username)
if user_id:
await twitchBot.twitch.delete_chat_messages(broadcaster_id, moderator_id, user_id=user_id)
_log_action("clean", "WebApp", username)
return {"success": True, "message": f"Messages de {username} supprimés"}
return {"success": False, "error": f"Utilisateur {username} introuvable"}
else:
await twitchBot.twitch.delete_chat_messages(broadcaster_id, moderator_id)
_log_action("clean", "WebApp", None, "Chat complet")
return {"success": True, "message": "Chat nettoyé"}
elif action == 'permit':
from database.models import TwitchPermit
username = params.get('username', '').strip().lstrip('@').lower()
duration = int(params.get('duration', 60)) # en secondes
expires_at = datetime.now() + timedelta(seconds=duration)
with webapp.app_context():
existing = TwitchPermit.query.filter_by(username=username).first()
if existing:
existing.expires_at = expires_at
else:
permit = TwitchPermit(username=username, expires_at=expires_at)
db.session.add(permit)
db.session.commit()
return {"success": True, "message": f"Permit accordé à {username} pour {duration//60}min"}
elif action in ['subon', 'suboff', 'emoteon', 'emoteoff']:
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _log_action
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
moderator_id = await _get_moderator_id(twitchBot.twitch)
if action == 'subon':
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=True)
_log_action("subon", "WebApp")
return {"success": True, "message": "Mode abonnés activé"}
elif action == 'suboff':
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=False)
_log_action("suboff", "WebApp")
return {"success": True, "message": "Mode abonnés désactivé"}
elif action == 'emoteon':
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=True)
_log_action("emoteon", "WebApp")
return {"success": True, "message": "Mode emote activé"}
elif action == 'emoteoff':
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=False)
_log_action("emoteoff", "WebApp")
return {"success": True, "message": "Mode emote désactivé"}
return {"success": False, "error": f"Action '{action}' non reconnue"}
except Exception as e:
import logging
logging.error(f"Erreur lors de l'exécution de l'action {action}: {e}")
return {"success": False, "error": str(e)}
# Exécuter la coroutine de manière synchrone
loop = asyncio.new_event_loop()
result = loop.run_until_complete(execute_action())
loop.close()
return jsonify(result)
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
+119
View File
@@ -0,0 +1,119 @@
# Gestion des utilisateurs webapp (réservé super administrateur).
from flask import render_template, request, redirect, url_for, flash
from werkzeug.security import generate_password_hash
from webapp import webapp
from webapp.auth import require_page
from database import db
from database.models import WebappUser, WebappRole
ROLE_LABELS = {
"viewer_twitch": "Viewer Twitch",
"utilisateur_discord": "Utilisateur Discord",
"moderateur_discord": "Modérateur Discord",
"expert_discord": "Expert Discord",
"moderateur_twitch": "Modérateur Twitch",
"super_administrateur": "Super administrateur",
}
def _role_labels():
roles = WebappRole.query.order_by(WebappRole.level).all()
return {r.name: r.name.replace("_", " ").title() for r in roles}
@webapp.route("/users")
@require_page("users")
def users_list():
users = WebappUser.query.order_by(WebappUser.created_at.desc()).all()
roles = WebappRole.query.order_by(WebappRole.level).all()
labels = dict(ROLE_LABELS)
labels.update(_role_labels())
return render_template(
"users.html",
users=users,
roles=[r.name for r in roles],
role_labels=labels,
)
@webapp.route("/users/role/<int:user_id>", methods=["POST"])
@require_page("users")
def users_set_role(user_id):
user = WebappUser.query.get_or_404(user_id)
new_role = request.form.get("role")
existing = WebappRole.query.filter_by(name=new_role).first()
if new_role and existing:
user.role = new_role
db.session.commit()
return redirect(url_for("users_list"))
@webapp.route("/users/create", methods=["POST"])
@require_page("users")
def users_create():
"""Création d'un utilisateur par un administrateur."""
username = (request.form.get("username") or "").strip()
email = (request.form.get("email") or "").strip().lower()
password = request.form.get("password") or ""
password_confirm = request.form.get("password_confirm") or ""
role = request.form.get("role") or "viewer_twitch"
errors = []
# Validations
if len(username) < 3:
errors.append("Le nom d'utilisateur doit faire au moins 3 caractères.")
if len(email) < 5 or "@" not in email:
errors.append("Adresse e-mail invalide.")
if len(password) < 8:
errors.append("Le mot de passe doit faire au moins 8 caractères.")
if password != password_confirm:
errors.append("Les mots de passe ne correspondent pas.")
if WebappUser.query.filter_by(username=username).first():
errors.append("Ce nom d'utilisateur est déjà pris.")
if WebappUser.query.filter_by(email=email).first():
errors.append("Cette adresse e-mail est déjà utilisée.")
# Vérifier que le rôle existe
if not WebappRole.query.filter_by(name=role).first():
errors.append("Rôle invalide.")
if errors:
for msg in errors:
flash(msg, "error")
return redirect(url_for("users_list"))
# Créer l'utilisateur
user = WebappUser(
username=username,
email=email,
password_hash=generate_password_hash(password, method="scrypt"),
role=role,
)
db.session.add(user)
db.session.commit()
flash(f"Utilisateur « {username} » créé avec succès.", "success")
return redirect(url_for("users_list"))
@webapp.route("/users/delete/<int:user_id>", methods=["POST"])
@require_page("users")
def users_delete(user_id):
"""Suppression d'un utilisateur (sauf soi-même)."""
from flask_login import current_user
user = WebappUser.query.get_or_404(user_id)
# Protection : impossible de se supprimer soi-même
if user.id == current_user.id:
flash("Vous ne pouvez pas supprimer votre propre compte.", "error")
return redirect(url_for("users_list"))
username = user.username
db.session.delete(user)
db.session.commit()
flash(f"Utilisateur « {username} » supprimé.", "success")
return redirect(url_for("users_list"))
+195
View File
@@ -0,0 +1,195 @@
import re
import requests
from urllib.parse import urlencode
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import YouTubeNotification
from discordbot import bot
def extract_channel_id(channel_input: str) -> str:
"""Extrait l'ID de la chaîne YouTube depuis différents formats"""
if not channel_input:
return None
channel_input = channel_input.strip()
if channel_input.startswith('UC') and len(channel_input) == 24:
return channel_input
if '/channel/' in channel_input:
match = re.search(r'/channel/([a-zA-Z0-9_-]{24})', channel_input)
if match:
return match.group(1)
if '/c/' in channel_input or '/user/' in channel_input:
parts = channel_input.split('/')
for i, part in enumerate(parts):
if part in ['c', 'user'] and i + 1 < len(parts):
handle = parts[i + 1].split('?')[0].split('&')[0]
channel_id = _get_channel_id_from_handle(handle)
if channel_id:
return channel_id
if '@' in channel_input:
handle = re.search(r'@([a-zA-Z0-9_-]+)', channel_input)
if handle:
channel_id = _get_channel_id_from_handle(handle.group(1))
if channel_id:
return channel_id
return None
def _get_channel_id_from_handle(handle: str) -> str:
"""Récupère l'ID de la chaîne depuis un handle en utilisant le flux RSS"""
try:
url = f"https://www.youtube.com/@{handle}"
response = requests.get(url, timeout=10, allow_redirects=True)
if response.status_code == 200:
channel_id_match = re.search(r'"channelId":"([^"]{24})"', response.text)
if channel_id_match:
return channel_id_match.group(1)
canonical_match = re.search(r'<link rel="canonical" href="https://www\.youtube\.com/channel/([^"]{24})"', response.text)
if canonical_match:
return canonical_match.group(1)
return None
except Exception:
return None
@webapp.route("/youtube")
@require_page("youtube")
def openYouTube():
notifications: list[YouTubeNotification] = YouTubeNotification.query.all()
channels = bot.getAllTextChannel()
for notification in notifications:
for channel in channels:
if notification.notify_channel == channel.id:
notification.notify_channel_name = channel.name
msg = request.args.get('msg')
msg_type = request.args.get('type', 'info')
return render_template("youtube.html", notifications=notifications, channels=channels, msg=msg, msg_type=msg_type)
@webapp.route("/youtube/add", methods=['POST'])
@require_page("youtube")
def addYouTube():
if not can_write_page("youtube"):
return render_template("403.html"), 403
channel_input = request.form.get('channel_id', '').strip()
channel_id = extract_channel_id(channel_input)
if not channel_id:
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': f"Impossible d'extraire l'ID de la chaîne depuis : {channel_input}. Veuillez vérifier le lien.", 'type': 'error'}))
notify_channel_str = request.form.get('notify_channel')
if not notify_channel_str:
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': "Veuillez sélectionner un canal Discord. Assurez-vous que le bot Discord est connecté.", 'type': 'error'}))
try:
notify_channel = int(notify_channel_str)
except ValueError:
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': "Canal Discord invalide.", 'type': 'error'}))
embed_color = request.form.get('embed_color', 'FF0000').strip().lstrip('#')
if len(embed_color) != 6:
embed_color = 'FF0000'
notification = YouTubeNotification(
enable=True,
channel_id=channel_id,
notify_channel=notify_channel,
message=request.form.get('message'),
video_type=request.form.get('video_type', 'all'),
embed_title=request.form.get('embed_title') or None,
embed_description=request.form.get('embed_description') or None,
embed_color=embed_color,
embed_footer=request.form.get('embed_footer') or None,
embed_author_name=request.form.get('embed_author_name') or None,
embed_author_icon=request.form.get('embed_author_icon') or None,
embed_thumbnail=request.form.get('embed_thumbnail') == 'on',
embed_image=request.form.get('embed_image') == 'on'
)
db.session.add(notification)
db.session.commit()
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': f"Notification ajoutée avec succès pour la chaîne {channel_id}", 'type': 'success'}))
@webapp.route("/youtube/toggle/<int:id>")
@require_page("youtube")
def toggleYouTube(id):
if not can_write_page("youtube"):
return render_template("403.html"), 403
notification: YouTubeNotification = YouTubeNotification.query.get_or_404(id)
notification.enable = not notification.enable
db.session.commit()
return redirect(url_for("openYouTube"))
@webapp.route("/youtube/edit/<int:id>")
@require_page("youtube")
def openEditYouTube(id):
notification = YouTubeNotification.query.get_or_404(id)
channels = bot.getAllTextChannel()
msg = request.args.get('msg')
msg_type = request.args.get('type', 'info')
return render_template("youtube.html", notification=notification, channels=channels, notifications=YouTubeNotification.query.all(), msg=msg, msg_type=msg_type)
@webapp.route("/youtube/edit/<int:id>", methods=['POST'])
@require_page("youtube")
def submitEditYouTube(id):
if not can_write_page("youtube"):
return render_template("403.html"), 403
notification: YouTubeNotification = YouTubeNotification.query.get_or_404(id)
channel_input = request.form.get('channel_id', '').strip()
channel_id = extract_channel_id(channel_input)
if not channel_id:
return redirect(url_for("openEditYouTube", id=id) + "?" + urlencode({'msg': f"Impossible d'extraire l'ID de la chaîne depuis : {channel_input}. Veuillez vérifier le lien.", 'type': 'error'}))
notify_channel_str = request.form.get('notify_channel')
if not notify_channel_str:
return redirect(url_for("openEditYouTube", id=id) + "?" + urlencode({'msg': "Veuillez sélectionner un canal Discord. Assurez-vous que le bot Discord est connecté.", 'type': 'error'}))
try:
notify_channel = int(notify_channel_str)
except ValueError:
return redirect(url_for("openEditYouTube", id=id) + "?" + urlencode({'msg': "Canal Discord invalide.", 'type': 'error'}))
embed_color = request.form.get('embed_color', 'FF0000').strip().lstrip('#')
if len(embed_color) != 6:
embed_color = 'FF0000'
notification.channel_id = channel_id
notification.notify_channel = notify_channel
notification.message = request.form.get('message')
notification.video_type = request.form.get('video_type', 'all')
notification.embed_title = request.form.get('embed_title') or None
notification.embed_description = request.form.get('embed_description') or None
notification.embed_color = embed_color
notification.embed_footer = request.form.get('embed_footer') or None
notification.embed_author_name = request.form.get('embed_author_name') or None
notification.embed_author_icon = request.form.get('embed_author_icon') or None
notification.embed_thumbnail = request.form.get('embed_thumbnail') == 'on'
notification.embed_image = request.form.get('embed_image') == 'on'
db.session.commit()
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': "Notification modifiée avec succès", 'type': 'success'}))
@webapp.route("/youtube/del/<int:id>")
@require_page("youtube")
def delYouTube(id):
if not can_write_page("youtube"):
return render_template("403.html"), 403
notification = YouTubeNotification.query.get_or_404(id)
db.session.delete(notification)
db.session.commit()
return redirect(url_for("openYouTube"))