Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b274dcaa51 | ||
|
|
d941fdcf9c | ||
|
|
48531690fd | ||
|
|
a8d2a0e063 | ||
|
|
f2cd19a053 | ||
|
|
4973144e54 | ||
|
|
9afd3b2588 |
@@ -22,13 +22,11 @@ RUN sed -i 's/# fr_FR.UTF-8 UTF-8/fr_FR.UTF-8 UTF-8/' /etc/locale.gen; \
|
||||
|
||||
COPY requirements.txt .
|
||||
COPY run-web.py .
|
||||
COPY shared_stats.py .
|
||||
COPY ./database ./database
|
||||
COPY ./discordbot ./discordbot
|
||||
COPY ./protondb ./protondb
|
||||
COPY ./webapp ./webapp
|
||||
COPY ./twitchbot ./twitchbot
|
||||
COPY ./youtubebot ./youtubebot
|
||||
COPY start.sh /start.sh
|
||||
|
||||
RUN python3 -m venv /app/venv && \
|
||||
|
||||
+228
-12
@@ -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()
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Migration: Ajout de la table twitch_banned_word
|
||||
-- Date: 2026-02-10
|
||||
-- Description: Permet de gérer les mots interdits dans le chat Twitch
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `twitch_banned_word` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`word` VARCHAR(256) UNIQUE NOT NULL,
|
||||
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`timeout_duration` INTEGER NOT NULL DEFAULT 60,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
+165
-43
@@ -1,4 +1,62 @@
|
||||
from datetime import datetime
|
||||
from database import db
|
||||
from flask_login import UserMixin
|
||||
|
||||
# Rôles par défaut (niveau 0 à 5) — seed en base via migration
|
||||
ROLE_ORDER = [
|
||||
"viewer_twitch",
|
||||
"utilisateur_discord",
|
||||
"moderateur_discord",
|
||||
"expert_discord",
|
||||
"moderateur_twitch",
|
||||
"super_administrateur",
|
||||
]
|
||||
|
||||
def role_level(role_name: str) -> int:
|
||||
"""Retourne le niveau du rôle depuis la table webapp_role (-1 si inconnu)."""
|
||||
if not role_name:
|
||||
return -1
|
||||
r = WebappRole.query.filter_by(name=role_name).first()
|
||||
return r.level if r else -1
|
||||
|
||||
class WebappRole(db.Model):
|
||||
__tablename__ = "webapp_role"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(64), unique=True, nullable=False)
|
||||
level = db.Column(db.Integer, nullable=False, default=0)
|
||||
description = db.Column(db.String(256), nullable=True)
|
||||
color = db.Column(db.String(7), nullable=True, default="#6B7280") # Couleur hexadécimale pour l'affichage
|
||||
icon = db.Column(db.String(32), nullable=True) # Nom d'icône (ex: 'user', 'shield', 'crown')
|
||||
|
||||
class PagePermission(db.Model):
|
||||
__tablename__ = "webapp_page_permission"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
page_key = db.Column(db.String(64), unique=True, nullable=False)
|
||||
min_level = db.Column(db.Integer, nullable=False, default=0)
|
||||
write_level = db.Column(db.Integer, nullable=True)
|
||||
category = db.Column(db.String(32), nullable=True, default="general") # Catégorie: 'general', 'moderation', 'content', 'config'
|
||||
description = db.Column(db.String(256), nullable=True) # Description de la page
|
||||
|
||||
class WebappUser(db.Model, UserMixin):
|
||||
__tablename__ = "webapp_user"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), unique=True, nullable=False)
|
||||
email = db.Column(db.String(256), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(256), nullable=False)
|
||||
role = db.Column(db.String(64), nullable=False, default="viewer_twitch")
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
def get_level(self) -> int:
|
||||
return role_level(self.role)
|
||||
|
||||
def has_role_at_least(self, min_role: str) -> bool:
|
||||
return self.get_level() >= role_level(min_role)
|
||||
|
||||
def has_level_at_least(self, level: int) -> bool:
|
||||
return self.get_level() >= level
|
||||
|
||||
def has_any_role(self, roles: list) -> bool:
|
||||
return self.role in roles
|
||||
|
||||
class Configuration(db.Model):
|
||||
key = db.Column(db.String(32), primary_key=True)
|
||||
@@ -25,20 +83,36 @@ class LiveAlert(db.Model):
|
||||
online = db.Column(db.Boolean, default=False)
|
||||
login = db.Column(db.String(128))
|
||||
notify_channel = db.Column(db.Integer)
|
||||
message = db.Column(db.String(2000))
|
||||
message = db.Column(db.String(2000)) # message optionnel avant l'embed
|
||||
watch_activity = db.Column(db.Boolean, default=False)
|
||||
# Personnalisation de l'embed Discord (comme YouTube)
|
||||
embed_title = db.Column(db.String(256))
|
||||
embed_description = db.Column(db.String(2000))
|
||||
embed_color = db.Column(db.String(8), default='9146FF') # violet Twitch
|
||||
embed_footer = db.Column(db.String(2048))
|
||||
embed_author_name = db.Column(db.String(256))
|
||||
embed_author_icon = db.Column(db.String(512))
|
||||
embed_thumbnail = db.Column(db.Boolean, default=True)
|
||||
embed_image = db.Column(db.Boolean, default=True)
|
||||
|
||||
class Message(db.Model):
|
||||
class TwitchAnnouncement(db.Model):
|
||||
__tablename__ = 'twitch_announcement'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
enable = db.Column(db.Boolean, default=False)
|
||||
text = db.Column(db.String(256))
|
||||
periodicity = db.Column(db.Integer)
|
||||
enable = db.Column(db.Boolean, default=True)
|
||||
name = db.Column(db.String(64))
|
||||
text = db.Column(db.String(500))
|
||||
periodicity = db.Column(db.Integer, default=10)
|
||||
min_chat_messages = db.Column(db.Integer, default=0)
|
||||
last_sent = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Niveaux de permission Twitch pour les commandes personnalisées : viewer, sub, vip, moderator
|
||||
class Commande(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
discord_enable = db.Column(db.Boolean, default=True)
|
||||
twitch_enable = db.Column(db.Boolean, default=True)
|
||||
trigger = db.Column(db.String(32), unique=True)
|
||||
response = db.Column(db.String(2000))
|
||||
twitch_permission = db.Column(db.String(16), default='viewer') # viewer | sub | vip | moderator
|
||||
|
||||
class ModerationEvent(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
@@ -51,6 +125,46 @@ class ModerationEvent(db.Model):
|
||||
staff_name = db.Column(db.String(256))
|
||||
duration = db.Column(db.Integer)
|
||||
|
||||
|
||||
class TwitchModerationLog(db.Model):
|
||||
__tablename__ = 'twitch_moderation_log'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
action = db.Column(db.String(32))
|
||||
moderator = db.Column(db.String(256))
|
||||
target = db.Column(db.String(256))
|
||||
details = db.Column(db.String(512))
|
||||
created_at = db.Column(db.DateTime)
|
||||
|
||||
|
||||
class TwitchLinkFilter(db.Model):
|
||||
__tablename__ = 'twitch_link_filter'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
enabled = db.Column(db.Boolean, default=False)
|
||||
allow_subscribers = db.Column(db.Boolean, default=True)
|
||||
allow_vips = db.Column(db.Boolean, default=True)
|
||||
allow_moderators = db.Column(db.Boolean, default=True)
|
||||
timeout_duration = db.Column(db.Integer, default=60)
|
||||
warning_message = db.Column(db.String(500), default="Les liens ne sont pas autorises dans le chat.")
|
||||
|
||||
|
||||
class TwitchAllowedDomain(db.Model):
|
||||
__tablename__ = 'twitch_allowed_domain'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
domain = db.Column(db.String(256), unique=True)
|
||||
|
||||
|
||||
class TwitchPermit(db.Model):
|
||||
__tablename__ = 'twitch_permit'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(256))
|
||||
expires_at = db.Column(db.DateTime)
|
||||
|
||||
|
||||
class TwitchAllowedUser(db.Model):
|
||||
__tablename__ = 'twitch_allowed_user'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(256), unique=True)
|
||||
|
||||
class AntiCheatCache(db.Model):
|
||||
__tablename__ = 'anticheat_cache'
|
||||
steam_id = db.Column(db.String(32), primary_key=True)
|
||||
@@ -61,47 +175,55 @@ class AntiCheatCache(db.Model):
|
||||
notes = db.Column(db.String(1024))
|
||||
updated_at = db.Column(db.DateTime)
|
||||
|
||||
class FreeGame(db.Model):
|
||||
__tablename__ = 'free_game'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
entry_id = db.Column(db.String(512), unique=True)
|
||||
title = db.Column(db.String(512))
|
||||
source = db.Column(db.String(64))
|
||||
url = db.Column(db.String(2048))
|
||||
image_url = db.Column(db.String(2048))
|
||||
description = db.Column(db.Text)
|
||||
valid_from = db.Column(db.DateTime)
|
||||
valid_to = db.Column(db.DateTime)
|
||||
notified = db.Column(db.Boolean, default=False)
|
||||
notified_at = db.Column(db.DateTime)
|
||||
created_at = db.Column(db.DateTime)
|
||||
|
||||
class DiscordInvite(db.Model):
|
||||
__tablename__ = 'discord_invite'
|
||||
code = db.Column(db.String(32), primary_key=True)
|
||||
guild_id = db.Column(db.String(64), nullable=False)
|
||||
channel_id = db.Column(db.String(64), nullable=False)
|
||||
channel_name = db.Column(db.String(256))
|
||||
inviter_id = db.Column(db.String(64))
|
||||
inviter_name = db.Column(db.String(256))
|
||||
uses = db.Column(db.Integer, default=0)
|
||||
max_uses = db.Column(db.Integer, default=0)
|
||||
max_age = db.Column(db.Integer, default=0)
|
||||
temporary = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime)
|
||||
expires_at = db.Column(db.DateTime)
|
||||
revoked = db.Column(db.Boolean, default=False)
|
||||
last_sync = db.Column(db.DateTime)
|
||||
|
||||
class YoutubeAlert(db.Model):
|
||||
__tablename__ = 'youtube_alert'
|
||||
class YouTubeNotification(db.Model):
|
||||
__tablename__ = 'youtube_notification'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
enable = db.Column(db.Boolean, default=True)
|
||||
channel_id = db.Column(db.String(64), unique=True)
|
||||
channel_name = db.Column(db.String(256))
|
||||
channel_id = db.Column(db.String(128))
|
||||
notify_channel = db.Column(db.Integer)
|
||||
message = db.Column(db.String(2000))
|
||||
last_video_id = db.Column(db.String(64))
|
||||
last_video_title = db.Column(db.String(512))
|
||||
last_video_date = db.Column(db.DateTime)
|
||||
video_type = db.Column(db.String(16), default='all')
|
||||
last_video_id = db.Column(db.String(128))
|
||||
embed_title = db.Column(db.String(256))
|
||||
embed_description = db.Column(db.String(2000))
|
||||
embed_color = db.Column(db.String(8), default='FF0000')
|
||||
embed_footer = db.Column(db.String(2048))
|
||||
embed_author_name = db.Column(db.String(256))
|
||||
embed_author_icon = db.Column(db.String(512))
|
||||
embed_thumbnail = db.Column(db.Boolean, default=True)
|
||||
embed_image = db.Column(db.Boolean, default=True)
|
||||
|
||||
|
||||
class FreeLootEntry(db.Model):
|
||||
__tablename__ = 'freeloot_entry'
|
||||
entry_id = db.Column(db.String(256), primary_key=True)
|
||||
|
||||
|
||||
class TwitchEventNotification(db.Model):
|
||||
"""Configuration des notifications par type d'événement (sub, follow, raid, clip)."""
|
||||
__tablename__ = 'twitch_event_notification'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_type = db.Column(db.String(32), unique=True, nullable=False) # sub, follow, raid, clip
|
||||
enable = db.Column(db.Boolean, default=True)
|
||||
notify_twitch_chat = db.Column(db.Boolean, default=True)
|
||||
notify_discord = db.Column(db.Boolean, default=False)
|
||||
discord_channel_id = db.Column(db.Integer, nullable=True)
|
||||
message_twitch = db.Column(db.String(500), default='')
|
||||
message_discord = db.Column(db.String(2000), nullable=True)
|
||||
embed_color = db.Column(db.String(8), default='9146FF')
|
||||
embed_title = db.Column(db.String(256), nullable=True)
|
||||
embed_description = db.Column(db.String(2000), nullable=True)
|
||||
embed_thumbnail = db.Column(db.Boolean, default=True)
|
||||
last_clip_id = db.Column(db.String(128), nullable=True) # pour détecter les nouveaux clips
|
||||
|
||||
|
||||
class TwitchBannedWord(db.Model):
|
||||
"""Mots interdits dans le chat Twitch."""
|
||||
__tablename__ = 'twitch_banned_word'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
word = db.Column(db.String(256), unique=True, nullable=False)
|
||||
enabled = db.Column(db.Boolean, default=True)
|
||||
timeout_duration = db.Column(db.Integer, default=60) # durée du timeout en secondes
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
+117
-39
@@ -28,14 +28,26 @@ CREATE TABLE IF NOT EXISTS live_alert (
|
||||
`online` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`login` VARCHAR(128) UNIQUE NOT NULL,
|
||||
`notify_channel` INTEGER NOT NULL,
|
||||
`message` VARCHAR(2000) NOT NULL
|
||||
`message` VARCHAR(2000),
|
||||
`watch_activity` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`embed_title` VARCHAR(256),
|
||||
`embed_description` VARCHAR(2000),
|
||||
`embed_color` VARCHAR(8) DEFAULT '9146FF',
|
||||
`embed_footer` VARCHAR(2048),
|
||||
`embed_author_name` VARCHAR(256),
|
||||
`embed_author_icon` VARCHAR(512),
|
||||
`embed_thumbnail` BOOLEAN DEFAULT TRUE,
|
||||
`embed_image` BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `message` (
|
||||
CREATE TABLE IF NOT EXISTS `twitch_announcement` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`enable` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`text` VARCHAR(256) NULL,
|
||||
periodicity INTEGER NULL
|
||||
`enable` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`name` VARCHAR(64) NOT NULL,
|
||||
`text` VARCHAR(500) NOT NULL,
|
||||
`periodicity` INTEGER NOT NULL DEFAULT 10,
|
||||
`min_chat_messages` INTEGER NOT NULL DEFAULT 0,
|
||||
`last_sent` DATETIME NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `commande` (
|
||||
@@ -43,7 +55,8 @@ CREATE TABLE IF NOT EXISTS `commande` (
|
||||
`discord_enable` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`twitch_enable` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`trigger` VARCHAR(16) UNIQUE NOT NULL,
|
||||
`response` VARCHAR(2000) NOT NULL
|
||||
`response` VARCHAR(2000) NOT NULL,
|
||||
`twitch_permission` VARCHAR(16) DEFAULT 'viewer'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `moderation_event` (
|
||||
@@ -58,6 +71,15 @@ CREATE TABLE IF NOT EXISTS `moderation_event` (
|
||||
`duration` INTEGER NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `twitch_moderation_log` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`action` VARCHAR(32) NOT NULL,
|
||||
`moderator` VARCHAR(256) NOT NULL,
|
||||
`target` VARCHAR(256),
|
||||
`details` VARCHAR(512),
|
||||
`created_at` DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `anticheat_cache` (
|
||||
steam_id VARCHAR(32) PRIMARY KEY,
|
||||
game_name VARCHAR(256) NOT NULL,
|
||||
@@ -77,46 +99,102 @@ CREATE TABLE IF NOT EXISTS `member_invites` (
|
||||
`join_date` DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `free_game` (
|
||||
CREATE TABLE IF NOT EXISTS `twitch_link_filter` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`entry_id` VARCHAR(512) UNIQUE NOT NULL,
|
||||
`title` VARCHAR(512) NOT NULL,
|
||||
`source` VARCHAR(64) NOT NULL,
|
||||
`url` VARCHAR(2048) NOT NULL,
|
||||
`image_url` VARCHAR(2048),
|
||||
`description` TEXT,
|
||||
`valid_from` DATETIME,
|
||||
`valid_to` DATETIME,
|
||||
`notified` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`notified_at` DATETIME,
|
||||
`created_at` DATETIME NOT NULL
|
||||
`enabled` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`allow_subscribers` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`allow_vips` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`allow_moderators` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`timeout_duration` INTEGER NOT NULL DEFAULT 60,
|
||||
`warning_message` VARCHAR(500) NOT NULL DEFAULT 'Les liens ne sont pas autorises dans le chat.'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `discord_invite` (
|
||||
`code` VARCHAR(32) PRIMARY KEY,
|
||||
`guild_id` VARCHAR(64) NOT NULL,
|
||||
`channel_id` VARCHAR(64) NOT NULL,
|
||||
`channel_name` VARCHAR(256),
|
||||
`inviter_id` VARCHAR(64),
|
||||
`inviter_name` VARCHAR(256),
|
||||
`uses` INTEGER NOT NULL DEFAULT 0,
|
||||
`max_uses` INTEGER NOT NULL DEFAULT 0,
|
||||
`max_age` INTEGER NOT NULL DEFAULT 0,
|
||||
`temporary` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`created_at` DATETIME,
|
||||
`expires_at` DATETIME,
|
||||
`revoked` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`last_sync` DATETIME NOT NULL
|
||||
CREATE TABLE IF NOT EXISTS `twitch_allowed_domain` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`domain` VARCHAR(256) UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `youtube_alert` (
|
||||
CREATE TABLE IF NOT EXISTS `twitch_permit` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`username` VARCHAR(256) NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `twitch_allowed_user` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`username` VARCHAR(256) UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `twitch_banned_word` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`word` VARCHAR(256) UNIQUE NOT NULL,
|
||||
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`timeout_duration` INTEGER NOT NULL DEFAULT 60,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `youtube_notification` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`enable` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`channel_id` VARCHAR(64) UNIQUE NOT NULL,
|
||||
`channel_name` VARCHAR(256),
|
||||
`channel_id` VARCHAR(128) NOT NULL,
|
||||
`notify_channel` INTEGER NOT NULL,
|
||||
`message` VARCHAR(2000) NOT NULL,
|
||||
`last_video_id` VARCHAR(64),
|
||||
`last_video_title` VARCHAR(512),
|
||||
`last_video_date` DATETIME
|
||||
`video_type` VARCHAR(16) NOT NULL DEFAULT 'all',
|
||||
`last_video_id` VARCHAR(128),
|
||||
`embed_title` VARCHAR(256),
|
||||
`embed_description` VARCHAR(2000),
|
||||
`embed_color` VARCHAR(8) NOT NULL DEFAULT 'FF0000',
|
||||
`embed_footer` VARCHAR(2048),
|
||||
`embed_author_name` VARCHAR(256),
|
||||
`embed_author_icon` VARCHAR(512),
|
||||
`embed_thumbnail` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`embed_image` BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `webapp_user` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(64) UNIQUE NOT NULL,
|
||||
email VARCHAR(256) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(256) NOT NULL,
|
||||
role VARCHAR(64) NOT NULL DEFAULT 'viewer_twitch',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `webapp_role` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(64) UNIQUE NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
description VARCHAR(256) NULL,
|
||||
color VARCHAR(7) NULL DEFAULT '#6B7280',
|
||||
icon VARCHAR(32) NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `webapp_page_permission` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_key VARCHAR(64) UNIQUE NOT NULL,
|
||||
min_level INTEGER NOT NULL DEFAULT 0,
|
||||
write_level INTEGER NULL,
|
||||
category VARCHAR(32) NULL DEFAULT 'general',
|
||||
description VARCHAR(256) NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `freeloot_entry` (
|
||||
entry_id VARCHAR(256) PRIMARY KEY
|
||||
);
|
||||
|
||||
-- Notifications d'événements Twitch (sub, follow, raid, clip) : chat Twitch et/ou canal Discord
|
||||
CREATE TABLE IF NOT EXISTS `twitch_event_notification` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type VARCHAR(32) UNIQUE NOT NULL,
|
||||
`enable` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
notify_twitch_chat BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
notify_discord BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
discord_channel_id INTEGER NULL,
|
||||
message_twitch VARCHAR(500) NOT NULL DEFAULT '',
|
||||
message_discord VARCHAR(2000) NULL,
|
||||
embed_color VARCHAR(8) DEFAULT '9146FF',
|
||||
embed_title VARCHAR(256) NULL,
|
||||
embed_description VARCHAR(2000) NULL,
|
||||
embed_thumbnail BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_clip_id VARCHAR(128) NULL
|
||||
);
|
||||
|
||||
+37
-234
@@ -3,13 +3,13 @@ import discord
|
||||
import logging
|
||||
import random
|
||||
|
||||
from webapp import webapp
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import Configuration, Humeur, Commande, DiscordInvite
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from discord import Message, TextChannel, Member
|
||||
from database.models import Configuration, Humeur, Commande
|
||||
from discord import Message, TextChannel, Member, VoiceChannel
|
||||
from discordbot.humblebundle import checkHumbleBundleAndNotify
|
||||
from discordbot.freegames import checkFreeGamesAndNotify
|
||||
from discordbot.freeloot import checkFreeLootAndNotify
|
||||
from discordbot.moderation import (
|
||||
handle_warning_command,
|
||||
handle_remove_warning_command,
|
||||
@@ -24,67 +24,28 @@ from discordbot.moderation import (
|
||||
handle_say_command
|
||||
)
|
||||
from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
|
||||
from discordbot.autorole import assignAutoRole
|
||||
from discordbot.youtube import checkYouTubeVideos
|
||||
from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms
|
||||
from protondb import searhProtonDb
|
||||
from shared_stats import stats_manager, discord_bridge
|
||||
|
||||
class DiscordBot(discord.Client):
|
||||
async def on_ready(self):
|
||||
logging.info(f'Connecté en tant que {self.user} (ID: {self.user.id})')
|
||||
webapp.config["BOT_STATUS"]["discord_connected"] = True
|
||||
webapp.config["BOT_STATUS"]["discord_guild_count"] = len(self.guilds)
|
||||
for c in self.get_all_channels() :
|
||||
logging.info(f'{c.id} {c.name}')
|
||||
|
||||
for guild in self.guilds:
|
||||
await updateInviteCache(guild)
|
||||
|
||||
# Enregistrer le bot dans le bridge pour communication avec Flask
|
||||
discord_bridge.register_bot(self, self.loop)
|
||||
|
||||
# Mise à jour des stats partagées
|
||||
self._update_shared_stats()
|
||||
|
||||
# Synchronisation initiale des invitations
|
||||
await self.syncInvites()
|
||||
|
||||
self.loop.create_task(self.updateStatus())
|
||||
self.loop.create_task(self.updateHumbleBundle())
|
||||
self.loop.create_task(self.updateFreeGames())
|
||||
self.loop.create_task(self.updateYoutubeAlerts())
|
||||
self.loop.create_task(self._periodic_stats_update())
|
||||
|
||||
def _update_shared_stats(self):
|
||||
"""Met à jour les statistiques partagées"""
|
||||
total_members = sum(g.member_count or 0 for g in self.guilds)
|
||||
total_channels = len(list(self.get_all_channels()))
|
||||
|
||||
stats_manager.update_discord_stats(
|
||||
connected=True,
|
||||
guilds=len(self.guilds),
|
||||
members=total_members,
|
||||
channels=total_channels,
|
||||
bot_name=str(self.user),
|
||||
bot_id=self.user.id
|
||||
)
|
||||
|
||||
# Mise à jour des cogs/fonctionnalités activées
|
||||
helper = ConfigurationHelper()
|
||||
cogs = {
|
||||
'Modération': helper.getValue('moderation_enable') or False,
|
||||
'Ban': helper.getValue('moderation_ban_enable') or False,
|
||||
'Kick': helper.getValue('moderation_kick_enable') or False,
|
||||
'ProtonDB': helper.getValue('proton_db_enable_enable') or False,
|
||||
'Humeurs': True, # Toujours actif si le bot tourne
|
||||
'Jeux Gratuits': helper.getValue('freegames_enable') or False,
|
||||
'Messages de bienvenue': helper.getValue('welcome_enable') or False,
|
||||
'Auto-Role': helper.getValue('autorole_enable') or False,
|
||||
}
|
||||
stats_manager.update_cogs(cogs)
|
||||
|
||||
async def _periodic_stats_update(self):
|
||||
"""Met à jour les stats périodiquement"""
|
||||
while not self.is_closed():
|
||||
await asyncio.sleep(60) # Toutes les minutes
|
||||
self._update_shared_stats()
|
||||
self.loop.create_task(self.updateYouTube())
|
||||
self.loop.create_task(self.updateFreeLoot())
|
||||
|
||||
async def on_disconnect(self):
|
||||
webapp.config["BOT_STATUS"]["discord_connected"] = False
|
||||
|
||||
async def updateStatus(self):
|
||||
while not self.is_closed():
|
||||
@@ -92,18 +53,8 @@ class DiscordBot(discord.Client):
|
||||
if len(humeurs)>0 :
|
||||
humeur = random.choice(humeurs)
|
||||
if humeur != None:
|
||||
# Récupérer les stats pour les variables
|
||||
total_members = sum(g.member_count or 0 for g in self.guilds)
|
||||
total_channels = len(list(self.get_all_channels()))
|
||||
|
||||
# Remplacer les variables dans le texte
|
||||
status_text = humeur.text
|
||||
status_text = status_text.replace('{servers}', str(len(self.guilds)))
|
||||
status_text = status_text.replace('{members}', str(total_members))
|
||||
status_text = status_text.replace('{channels}', str(total_channels))
|
||||
|
||||
logging.info(f'Changement de statut : {status_text}')
|
||||
await self.change_presence(status = discord.Status.online, activity = discord.CustomActivity(status_text))
|
||||
logging.info(f'Changement de statut : {humeur.text}')
|
||||
await self.change_presence(status = discord.Status.online, activity = discord.CustomActivity(humeur.text))
|
||||
await asyncio.sleep(10*60)
|
||||
|
||||
async def updateHumbleBundle(self):
|
||||
@@ -111,16 +62,15 @@ class DiscordBot(discord.Client):
|
||||
await checkHumbleBundleAndNotify(self)
|
||||
await asyncio.sleep(30*60)
|
||||
|
||||
async def updateFreeGames(self):
|
||||
async def updateYouTube(self):
|
||||
while not self.is_closed():
|
||||
await checkFreeGamesAndNotify(self)
|
||||
await asyncio.sleep(60*60) # Vérification toutes les heures
|
||||
await checkYouTubeVideos()
|
||||
await asyncio.sleep(5*60)
|
||||
|
||||
async def updateYoutubeAlerts(self):
|
||||
from youtubebot.youtube_alert import checkNewVideos
|
||||
async def updateFreeLoot(self):
|
||||
while not self.is_closed():
|
||||
await checkNewVideos(self)
|
||||
await asyncio.sleep(10*60) # Vérification toutes les 10 minutes
|
||||
await checkFreeLootAndNotify(self)
|
||||
await asyncio.sleep(30*60)
|
||||
|
||||
def getAllTextChannel(self) -> list[TextChannel]:
|
||||
channels = []
|
||||
@@ -128,7 +78,14 @@ class DiscordBot(discord.Client):
|
||||
if isinstance(channel, TextChannel):
|
||||
channels.append(channel)
|
||||
return channels
|
||||
|
||||
|
||||
def getAllVoiceChannels(self) -> list[VoiceChannel]:
|
||||
channels = []
|
||||
for channel in self.get_all_channels():
|
||||
if isinstance(channel, VoiceChannel):
|
||||
channels.append(channel)
|
||||
return channels
|
||||
|
||||
def getAllRoles(self):
|
||||
guilds_roles = []
|
||||
for guild in self.guilds:
|
||||
@@ -143,156 +100,7 @@ class DiscordBot(discord.Client):
|
||||
'roles': roles
|
||||
})
|
||||
return guilds_roles
|
||||
|
||||
def getAllGuilds(self):
|
||||
"""Retourne la liste de tous les serveurs Discord"""
|
||||
guilds_list = []
|
||||
for guild in self.guilds:
|
||||
guilds_list.append({
|
||||
'id': guild.id,
|
||||
'name': guild.name,
|
||||
'member_count': guild.member_count,
|
||||
'icon_url': str(guild.icon.url) if guild.icon else None,
|
||||
'owner_id': guild.owner_id
|
||||
})
|
||||
return guilds_list
|
||||
|
||||
async def leaveGuild(self, guild_id: int) -> bool:
|
||||
"""Quitte un serveur Discord par son ID"""
|
||||
guild = self.get_guild(guild_id)
|
||||
if guild:
|
||||
await guild.leave()
|
||||
logging.info(f'Le bot a quitté le serveur : {guild.name} (ID: {guild_id})')
|
||||
return True
|
||||
return False
|
||||
|
||||
async def syncInvites(self, guild_id: int = None) -> dict:
|
||||
"""Synchronise les invitations Discord avec la base de données"""
|
||||
result = {'synced': 0, 'errors': [], 'guilds': []}
|
||||
|
||||
guilds_to_sync = [self.get_guild(guild_id)] if guild_id else self.guilds
|
||||
|
||||
for guild in guilds_to_sync:
|
||||
if not guild:
|
||||
continue
|
||||
|
||||
try:
|
||||
invites = await guild.invites()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Marquer les invitations existantes comme révoquées (on les réactivera si elles existent encore)
|
||||
existing_invites = DiscordInvite.query.filter_by(guild_id=str(guild.id), revoked=False).all()
|
||||
existing_codes = {inv.code for inv in existing_invites}
|
||||
current_codes = {inv.code for inv in invites}
|
||||
|
||||
# Marquer comme révoquées celles qui n'existent plus
|
||||
for inv in existing_invites:
|
||||
if inv.code not in current_codes:
|
||||
inv.revoked = True
|
||||
inv.last_sync = now
|
||||
|
||||
for invite in invites:
|
||||
# Calculer la date d'expiration
|
||||
expires_at = None
|
||||
if invite.max_age and invite.max_age > 0 and invite.created_at:
|
||||
expires_at = invite.created_at + timedelta(seconds=invite.max_age)
|
||||
|
||||
# Chercher si l'invitation existe déjà
|
||||
db_invite = DiscordInvite.query.filter_by(code=invite.code).first()
|
||||
|
||||
if db_invite:
|
||||
# Mettre à jour l'invitation existante
|
||||
db_invite.uses = invite.uses or 0
|
||||
db_invite.revoked = False
|
||||
db_invite.last_sync = now
|
||||
db_invite.channel_name = invite.channel.name if invite.channel else None
|
||||
db_invite.inviter_name = invite.inviter.name if invite.inviter else None
|
||||
else:
|
||||
# Créer une nouvelle invitation
|
||||
db_invite = DiscordInvite(
|
||||
code=invite.code,
|
||||
guild_id=str(guild.id),
|
||||
channel_id=str(invite.channel.id) if invite.channel else '',
|
||||
channel_name=invite.channel.name if invite.channel else None,
|
||||
inviter_id=str(invite.inviter.id) if invite.inviter else None,
|
||||
inviter_name=invite.inviter.name if invite.inviter else None,
|
||||
uses=invite.uses or 0,
|
||||
max_uses=invite.max_uses or 0,
|
||||
max_age=invite.max_age or 0,
|
||||
temporary=invite.temporary or False,
|
||||
created_at=invite.created_at,
|
||||
expires_at=expires_at,
|
||||
revoked=False,
|
||||
last_sync=now
|
||||
)
|
||||
db.session.add(db_invite)
|
||||
|
||||
result['synced'] += 1
|
||||
|
||||
db.session.commit()
|
||||
result['guilds'].append({'id': guild.id, 'name': guild.name, 'invites': len(invites)})
|
||||
logging.info(f'Invitations synchronisées pour {guild.name}: {len(invites)} invitations')
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur lors de la synchronisation des invitations pour {guild.name}: {e}')
|
||||
result['errors'].append(f'{guild.name}: {str(e)}')
|
||||
|
||||
return result
|
||||
|
||||
async def revokeInvite(self, invite_code: str) -> dict:
|
||||
"""Révoque une invitation Discord"""
|
||||
result = {'success': False, 'message': '', 'invite_code': invite_code}
|
||||
|
||||
try:
|
||||
# Chercher l'invitation dans la BDD
|
||||
db_invite = DiscordInvite.query.filter_by(code=invite_code).first()
|
||||
if not db_invite:
|
||||
result['message'] = 'Invitation non trouvée dans la base de données'
|
||||
return result
|
||||
|
||||
# Récupérer le guild
|
||||
guild = self.get_guild(int(db_invite.guild_id))
|
||||
if not guild:
|
||||
result['message'] = 'Serveur Discord non trouvé'
|
||||
return result
|
||||
|
||||
# Chercher l'invitation sur Discord
|
||||
try:
|
||||
invites = await guild.invites()
|
||||
discord_invite = next((inv for inv in invites if inv.code == invite_code), None)
|
||||
|
||||
if discord_invite:
|
||||
await discord_invite.delete(reason='Révoquée via interface web')
|
||||
logging.info(f'Invitation {invite_code} révoquée sur Discord')
|
||||
|
||||
except Exception as e:
|
||||
logging.warning(f'Impossible de révoquer l\'invitation sur Discord: {e}')
|
||||
|
||||
# Marquer comme révoquée dans la BDD
|
||||
db_invite.revoked = True
|
||||
db_invite.last_sync = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
|
||||
result['success'] = True
|
||||
result['message'] = f'Invitation {invite_code} révoquée avec succès'
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur lors de la révocation de l\'invitation {invite_code}: {e}')
|
||||
result['message'] = f'Erreur: {str(e)}'
|
||||
|
||||
return result
|
||||
|
||||
def getInvites(self, guild_id: int = None, include_revoked: bool = False) -> list:
|
||||
"""Récupère les invitations depuis la base de données"""
|
||||
query = DiscordInvite.query
|
||||
|
||||
if guild_id:
|
||||
query = query.filter_by(guild_id=str(guild_id))
|
||||
|
||||
if not include_revoked:
|
||||
query = query.filter_by(revoked=False)
|
||||
|
||||
return query.order_by(DiscordInvite.created_at.desc()).all()
|
||||
|
||||
def begin(self) :
|
||||
token = Configuration.query.filter_by(key='discord_token').first()
|
||||
@@ -468,10 +276,17 @@ async def on_message(message: Message):
|
||||
except Exception as e:
|
||||
logging.error(f"Échec de l'envoi de l'embed ProtonDB : {e}")
|
||||
|
||||
@bot.event
|
||||
async def on_voice_state_update(member: Member, before, after):
|
||||
await on_voice_state_update_auto_rooms(bot, member, before, after)
|
||||
|
||||
@bot.event
|
||||
async def on_raw_reaction_add(payload: discord.RawReactionActionEvent):
|
||||
await on_raw_reaction_add_auto_rooms(bot, payload)
|
||||
|
||||
@bot.event
|
||||
async def on_member_join(member: Member):
|
||||
await sendWelcomeMessage(bot, member)
|
||||
await assignAutoRole(bot, member)
|
||||
|
||||
@bot.event
|
||||
async def on_member_remove(member: Member):
|
||||
@@ -480,20 +295,8 @@ async def on_member_remove(member: Member):
|
||||
@bot.event
|
||||
async def on_invite_create(invite):
|
||||
await updateInviteCache(invite.guild)
|
||||
# Synchroniser la nouvelle invitation avec la BDD
|
||||
await bot.syncInvites(invite.guild.id)
|
||||
|
||||
@bot.event
|
||||
async def on_invite_delete(invite):
|
||||
await updateInviteCache(invite.guild)
|
||||
# Marquer l'invitation comme révoquée dans la BDD
|
||||
try:
|
||||
db_invite = DiscordInvite.query.filter_by(code=invite.code).first()
|
||||
if db_invite:
|
||||
db_invite.revoked = True
|
||||
db_invite.last_sync = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
logging.info(f'Invitation {invite.code} marquée comme révoquée')
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur lors de la révocation de l\'invitation {invite.code}: {e}')
|
||||
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
# discordbot/auto_rooms.py — Auto rooms : message et réactions dans la partie texte du salon vocal (onglet Discussion)
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import discord
|
||||
from discord import Member, VoiceState
|
||||
from database.helpers import ConfigurationHelper
|
||||
|
||||
# (guild_id, owner_id) -> room_data (voice_channel_id, control_message_id, whitelist, blacklist, access_mode)
|
||||
_rooms: dict[tuple[int, int], dict] = {}
|
||||
|
||||
# message_id -> (guild_id, owner_id) pour retrouver la room depuis une réaction
|
||||
_control_message_ids: dict[int, tuple[int, int]] = {}
|
||||
|
||||
# Emoji -> action
|
||||
REACTIONS = [
|
||||
("🔓", "open", "Ouvert"),
|
||||
("🔒", "closed", "Fermé"),
|
||||
("🛡️", "private", "Privé"),
|
||||
("✅", "whitelist", "Liste blanche"),
|
||||
("🚫", "blacklist", "Liste noire"),
|
||||
("🧹", "purge", "Purge"),
|
||||
("👑", "transfer", "Propriété"),
|
||||
("🎤", "speak", "Micro"),
|
||||
("📹", "stream", "Vidéo"),
|
||||
("📝", "status", "Statut"),
|
||||
]
|
||||
|
||||
|
||||
def _status_display(access_mode: str) -> str:
|
||||
"""Cadenas ouvert ou fermé selon si le salon est ouvert ou pas."""
|
||||
if access_mode == "open":
|
||||
return "🔓 Ouvert"
|
||||
if access_mode == "closed":
|
||||
return "🔒 Fermé"
|
||||
if access_mode == "private":
|
||||
return "🔒 Privé"
|
||||
return "🔓 Ouvert"
|
||||
|
||||
|
||||
def _status_emoji(access_mode: str) -> str:
|
||||
"""Emoji cadenas seul pour le nom du channel."""
|
||||
return "🔓" if access_mode == "open" else "🔒"
|
||||
|
||||
|
||||
def _build_control_embed(owner: Member, voice_channel: discord.VoiceChannel, access_mode: str) -> discord.Embed:
|
||||
"""Construit l’embed de config avec infos du salon."""
|
||||
embed = discord.Embed(
|
||||
title="Configuration du salon",
|
||||
description=(
|
||||
"Voici l’espace de configuration de votre salon vocal. "
|
||||
"Utilisez les réactions ci-dessous — seul le propriétaire peut réagir."
|
||||
),
|
||||
color=discord.Color.blurple()
|
||||
)
|
||||
members_count = len(voice_channel.members)
|
||||
user_limit = voice_channel.user_limit or 0
|
||||
limit_text = f"{user_limit} max" if user_limit else "Illimitée"
|
||||
members_text = f"{members_count} / {user_limit}" if user_limit else str(members_count)
|
||||
bitrate_kbps = (voice_channel.bitrate or 0) // 1000
|
||||
|
||||
embed.add_field(name="Propriétaire", value=owner.mention, inline=True)
|
||||
embed.add_field(name="Statut du salon", value=_status_display(access_mode), inline=True)
|
||||
embed.add_field(name="Nom du salon", value=voice_channel.name, inline=True)
|
||||
embed.add_field(name="Membres", value=members_text, inline=True)
|
||||
embed.add_field(name="Limite", value=limit_text, inline=True)
|
||||
embed.add_field(name="Bitrate", value=f"{bitrate_kbps} kbps", inline=True)
|
||||
embed.add_field(name="Accès", value="🔓 Ouvert · 🔒 Fermé · 🛡️ Privé", inline=False)
|
||||
embed.add_field(name="Listes", value="✅ Liste blanche · 🚫 Liste noire", inline=False)
|
||||
embed.add_field(name="Actions", value="🧹 Purge · 👑 Propriété · 🎤 Micro · 📹 Vidéo · 📝 Statut", inline=False)
|
||||
return embed
|
||||
|
||||
|
||||
def _room_key(guild_id: int, owner_id: int) -> tuple[int, int]:
|
||||
return (guild_id, owner_id)
|
||||
|
||||
|
||||
def _get_room(guild_id: int, owner_id: int) -> Optional[dict]:
|
||||
return _rooms.get(_room_key(guild_id, owner_id))
|
||||
|
||||
|
||||
def _set_room(guild_id: int, owner_id: int, data: dict):
|
||||
_rooms[_room_key(guild_id, owner_id)] = data
|
||||
mid = data.get("control_message_id")
|
||||
if mid:
|
||||
_control_message_ids[mid] = (guild_id, owner_id)
|
||||
|
||||
|
||||
def _del_room(guild_id: int, owner_id: int):
|
||||
data = _rooms.pop(_room_key(guild_id, owner_id), None)
|
||||
if data and data.get("control_message_id"):
|
||||
_control_message_ids.pop(data["control_message_id"], None)
|
||||
|
||||
|
||||
def _find_room_by_channel(guild_id: int, channel_id: int) -> Optional[tuple[int, dict]]:
|
||||
for (gid, oid), data in _rooms.items():
|
||||
if gid == guild_id and data.get("voice_channel_id") == channel_id:
|
||||
return (oid, data)
|
||||
return None
|
||||
|
||||
|
||||
def _find_room_by_message(message_id: int) -> Optional[tuple[int, int, dict]]:
|
||||
key = _control_message_ids.get(message_id)
|
||||
if not key:
|
||||
return None
|
||||
guild_id, owner_id = key
|
||||
data = _get_room(guild_id, owner_id)
|
||||
if not data:
|
||||
_control_message_ids.pop(message_id, None)
|
||||
return None
|
||||
return (guild_id, owner_id, data)
|
||||
|
||||
|
||||
async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist: set, blacklist: set):
|
||||
guild = channel.guild
|
||||
everyone = guild.default_role
|
||||
overwrites = {}
|
||||
everyone_ow = discord.PermissionOverwrite()
|
||||
if mode == "open":
|
||||
everyone_ow.connect = True
|
||||
everyone_ow.view_channel = True
|
||||
for uid in blacklist:
|
||||
m = guild.get_member(uid)
|
||||
if m:
|
||||
overwrites[m] = discord.PermissionOverwrite(connect=False, view_channel=True)
|
||||
elif mode == "closed":
|
||||
everyone_ow.connect = False
|
||||
everyone_ow.view_channel = True
|
||||
for uid in whitelist:
|
||||
m = guild.get_member(uid)
|
||||
if m:
|
||||
overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True)
|
||||
elif mode == "private":
|
||||
everyone_ow.connect = False
|
||||
everyone_ow.view_channel = False
|
||||
for uid in whitelist:
|
||||
m = guild.get_member(uid)
|
||||
if m:
|
||||
overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True)
|
||||
overwrites[everyone] = everyone_ow
|
||||
await channel.edit(overwrites=overwrites)
|
||||
|
||||
|
||||
async def _handle_reaction_action(bot: discord.Client, guild_id: int, owner_id: int, action: str, channel):
|
||||
"""channel = salon vocal (partie texte / onglet Discussion)."""
|
||||
room = _get_room(guild_id, owner_id)
|
||||
if not room:
|
||||
await channel.send("Ce salon n’existe plus.")
|
||||
return
|
||||
voice_channel = bot.get_channel(room["voice_channel_id"])
|
||||
if not voice_channel or not isinstance(voice_channel, discord.VoiceChannel):
|
||||
await channel.send("Salon vocal introuvable.")
|
||||
return
|
||||
|
||||
if action in ("open", "closed", "private"):
|
||||
room["access_mode"] = action
|
||||
await _apply_access_mode(voice_channel, action, room.get("whitelist", set()), room.get("blacklist", set()))
|
||||
# Mettre à jour le cadenas dans le nom du channel
|
||||
try:
|
||||
base_name = voice_channel.name.rstrip(" 🔓🔒")
|
||||
new_name = f"{base_name} {_status_emoji(action)}"
|
||||
await voice_channel.edit(name=new_name)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
await channel.send(f"Accès du salon défini sur **{action}**.")
|
||||
# Mettre à jour uniquement le statut (cadenas) dans le message de config
|
||||
control_message_id = room.get("control_message_id")
|
||||
if control_message_id:
|
||||
try:
|
||||
msg = await channel.fetch_message(control_message_id)
|
||||
if msg.embeds:
|
||||
embed = msg.embeds[0].copy()
|
||||
for i, f in enumerate(embed.fields):
|
||||
if f.name == "Statut du salon":
|
||||
embed.set_field_at(i, name="Statut du salon", value=_status_display(action), inline=f.inline)
|
||||
break
|
||||
else:
|
||||
embed.add_field(name="Statut du salon", value=_status_display(action), inline=False)
|
||||
await msg.edit(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
elif action == "whitelist":
|
||||
await channel.send("Liste blanche : mentionnez un membre pour l’ajouter/retirer.")
|
||||
|
||||
elif action == "blacklist":
|
||||
await channel.send("Liste noire : mentionnez un membre pour l’ajouter/retirer.")
|
||||
|
||||
elif action == "purge":
|
||||
whitelist = room.get("whitelist", set())
|
||||
kicked = 0
|
||||
for member in list(voice_channel.members):
|
||||
if member.id == owner_id or member.id in whitelist:
|
||||
continue
|
||||
try:
|
||||
await member.move_to(None)
|
||||
kicked += 1
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
await channel.send(f"Purge effectuée : {kicked} membre(s) déconnecté(s).")
|
||||
|
||||
elif action == "transfer":
|
||||
await channel.send("Transférer le salon : mentionnez le membre à qui donner la propriété.")
|
||||
|
||||
elif action in ("speak", "stream"):
|
||||
everyone = voice_channel.guild.default_role
|
||||
overwrites = dict(voice_channel.overwrites)
|
||||
ow = overwrites.get(everyone) or discord.PermissionOverwrite()
|
||||
current = getattr(ow, action)
|
||||
setattr(ow, action, not current if current is not None else False)
|
||||
overwrites[everyone] = ow
|
||||
await voice_channel.edit(overwrites=overwrites)
|
||||
label = "Micro" if action == "speak" else "Vidéo"
|
||||
await channel.send(f"{label} : {'autorisé' if getattr(ow, action) else 'désactivé'} pour tous.")
|
||||
|
||||
elif action == "status":
|
||||
status_text = _status_display(room.get("access_mode", "open"))
|
||||
await channel.send(f"Statut du salon : {status_text}\nRépondez avec le nouveau nom du salon pour le modifier.")
|
||||
|
||||
|
||||
async def send_control_panel(bot: discord.Client, guild_id: int, owner: Member, voice_channel: discord.VoiceChannel) -> Optional[int]:
|
||||
"""Envoie le message de config avec réactions dans la partie texte du salon vocal (onglet Discussion). Seul le proprio peut réagir. Retourne l’id du message."""
|
||||
embed = _build_control_embed(owner, voice_channel, "open")
|
||||
|
||||
try:
|
||||
# Message dans la partie texte du vocal (onglet Discussion à droite)
|
||||
msg = await voice_channel.send(embed=embed)
|
||||
for emoji, _action, _label in REACTIONS:
|
||||
await msg.add_reaction(emoji)
|
||||
return msg.id
|
||||
except discord.HTTPException as e:
|
||||
logging.error(f"Impossible d’envoyer le panneau Auto Room dans le vocal : {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member, before: VoiceState, after: VoiceState):
|
||||
config = ConfigurationHelper()
|
||||
if not config.getValue("auto_rooms_enable"):
|
||||
return
|
||||
trigger_channel_id = config.getIntValue("auto_rooms_channel_id")
|
||||
if not trigger_channel_id:
|
||||
return
|
||||
|
||||
guild = member.guild
|
||||
|
||||
if after.channel and after.channel.id == trigger_channel_id:
|
||||
category = after.channel.category
|
||||
# Nom du salon avec statut (cadenas) à la création
|
||||
channel_name = f"Salon de {member.display_name} {_status_emoji('open')}"
|
||||
try:
|
||||
new_channel = await guild.create_voice_channel(
|
||||
name=channel_name,
|
||||
category=category,
|
||||
reason="Auto room"
|
||||
)
|
||||
await member.move_to(new_channel)
|
||||
control_message_id = await send_control_panel(bot, guild.id, member, new_channel)
|
||||
_set_room(guild.id, member.id, {
|
||||
"guild_id": guild.id,
|
||||
"voice_channel_id": new_channel.id,
|
||||
"control_message_id": control_message_id,
|
||||
"owner_id": member.id,
|
||||
"whitelist": set(),
|
||||
"blacklist": set(),
|
||||
"access_mode": "open",
|
||||
})
|
||||
logging.info(f"Auto room créé : {new_channel.name} pour {member.display_name}")
|
||||
except discord.HTTPException as e:
|
||||
logging.error(f"Erreur création auto room : {e}")
|
||||
|
||||
if before.channel and before.channel.id != trigger_channel_id:
|
||||
result = _find_room_by_channel(guild.id, before.channel.id)
|
||||
if result:
|
||||
owner_id, room = result
|
||||
remaining = [m for m in before.channel.members if m.id != member.id]
|
||||
if member.id == owner_id:
|
||||
_del_room(guild.id, owner_id)
|
||||
try:
|
||||
await before.channel.delete(reason="Propriétaire parti (auto room)")
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
elif len(remaining) == 0:
|
||||
_del_room(guild.id, owner_id)
|
||||
try:
|
||||
await before.channel.delete(reason="Auto room vide")
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
|
||||
async def on_raw_reaction_add_auto_rooms(bot: discord.Client, payload: discord.RawReactionActionEvent):
|
||||
"""Seul le propriétaire peut réagir ; on retire la réaction des autres."""
|
||||
if payload.user_id == bot.user.id:
|
||||
return
|
||||
if not ConfigurationHelper().getValue("auto_rooms_enable"):
|
||||
return
|
||||
room_info = _find_room_by_message(payload.message_id)
|
||||
if not room_info:
|
||||
return
|
||||
guild_id, owner_id, room = room_info
|
||||
if payload.user_id != owner_id:
|
||||
try:
|
||||
channel = bot.get_channel(payload.channel_id)
|
||||
if channel:
|
||||
msg = await channel.fetch_message(payload.message_id)
|
||||
user = payload.member or await bot.fetch_user(payload.user_id)
|
||||
await msg.remove_reaction(payload.emoji, user)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
return
|
||||
|
||||
emoji_str = str(payload.emoji)
|
||||
action = None
|
||||
for e, a, _ in REACTIONS:
|
||||
if e == emoji_str:
|
||||
action = a
|
||||
break
|
||||
if not action:
|
||||
return
|
||||
|
||||
# Canal = salon vocal (le message est dans la partie texte du vocal)
|
||||
channel = bot.get_channel(payload.channel_id)
|
||||
if not channel or not hasattr(channel, "send"):
|
||||
return
|
||||
|
||||
await _handle_reaction_action(bot, guild_id, owner_id, action, channel)
|
||||
|
||||
try:
|
||||
msg = await channel.fetch_message(payload.message_id)
|
||||
user = payload.member or await bot.fetch_user(payload.user_id)
|
||||
await msg.remove_reaction(payload.emoji, user)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
@@ -1,33 +0,0 @@
|
||||
import discord
|
||||
import logging
|
||||
from database.helpers import ConfigurationHelper
|
||||
from discord import Member
|
||||
|
||||
async def assignAutoRole(bot: discord.Client, member: Member):
|
||||
"""Attribue automatiquement un rôle aux nouveaux membres"""
|
||||
config = ConfigurationHelper()
|
||||
|
||||
if not config.getValue('autorole_enable'):
|
||||
return
|
||||
|
||||
role_id = config.getIntValue('autorole_role_id')
|
||||
if not role_id:
|
||||
logging.warning('Auto-role activé mais aucun rôle configuré')
|
||||
return
|
||||
|
||||
# Chercher le rôle dans le serveur du membre
|
||||
role = member.guild.get_role(role_id)
|
||||
if not role:
|
||||
logging.error(f'Rôle auto-role {role_id} introuvable dans le serveur {member.guild.name}')
|
||||
return
|
||||
|
||||
try:
|
||||
await member.add_roles(role, reason='Auto-role: Attribution automatique à l\'arrivée')
|
||||
logging.info(f'Auto-role: Rôle "{role.name}" attribué à {member.name} sur {member.guild.name}')
|
||||
except discord.Forbidden:
|
||||
logging.error(f'Auto-role: Permission refusée pour attribuer le rôle "{role.name}" à {member.name}')
|
||||
except discord.HTTPException as e:
|
||||
logging.error(f'Auto-role: Erreur HTTP lors de l\'attribution du rôle : {e}')
|
||||
except Exception as e:
|
||||
logging.error(f'Auto-role: Erreur inattendue : {e}')
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import discord
|
||||
import feedparser
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from html import unescape
|
||||
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import FreeGame
|
||||
from discord import Client
|
||||
|
||||
RSS_URL = "https://feed.eikowagenknecht.com/lootscraper.xml"
|
||||
|
||||
KNOWN_SOURCES = {
|
||||
'epic': 'Epic Games',
|
||||
'steam': 'Steam',
|
||||
'gog': 'GOG',
|
||||
'amazon': 'Amazon Prime',
|
||||
'humble': 'Humble Bundle',
|
||||
'apple': 'Apple App Store',
|
||||
'google': 'Google Play',
|
||||
'itch': 'Itch.io',
|
||||
'ubisoft': 'Ubisoft',
|
||||
'indiegala': 'IndieGala'
|
||||
}
|
||||
|
||||
def _isEnabled():
|
||||
helper = ConfigurationHelper()
|
||||
return helper.getValue('freegames_enable') and helper.getIntValue('freegames_channel') != 0
|
||||
|
||||
def _parseSource(title: str) -> str:
|
||||
title_lower = title.lower()
|
||||
for key, name in KNOWN_SOURCES.items():
|
||||
if key in title_lower:
|
||||
return name
|
||||
match = re.search(r'\(([^)]+)\)', title)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return "Autre"
|
||||
|
||||
def _parseEntryContent(entry) -> dict:
|
||||
content = entry.get('content', [{}])[0].get('value', '') if entry.get('content') else ''
|
||||
summary = entry.get('summary', '')
|
||||
|
||||
image_url = None
|
||||
img_match = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', content or summary)
|
||||
if img_match:
|
||||
image_url = img_match.group(1)
|
||||
|
||||
valid_from = None
|
||||
valid_to = None
|
||||
|
||||
from_match = re.search(r'Offer valid from:</b>\s*([^<]+)', content)
|
||||
if from_match:
|
||||
try:
|
||||
date_str = from_match.group(1).strip()
|
||||
valid_from = datetime.strptime(date_str, '%Y-%m-%d %H:%M')
|
||||
except:
|
||||
pass
|
||||
|
||||
to_match = re.search(r'Offer valid to:</b>\s*([^<]+)', content)
|
||||
if to_match:
|
||||
try:
|
||||
date_str = to_match.group(1).strip()
|
||||
valid_to = datetime.strptime(date_str, '%Y-%m-%d %H:%M')
|
||||
except:
|
||||
pass
|
||||
|
||||
description = None
|
||||
desc_match = re.search(r'Description:</b>\s*([^<]+)', content)
|
||||
if desc_match:
|
||||
description = unescape(desc_match.group(1).strip())
|
||||
|
||||
price = None
|
||||
price_match = re.search(r'Recommended price[^:]*:</b>\s*([^<]+)', content)
|
||||
if price_match:
|
||||
price = price_match.group(1).strip()
|
||||
|
||||
genres = []
|
||||
for category in entry.get('tags', []):
|
||||
term = category.get('term', '')
|
||||
if term.startswith('Genre:'):
|
||||
genres.append(term.replace('Genre:', '').strip())
|
||||
|
||||
return {
|
||||
'image_url': image_url,
|
||||
'valid_from': valid_from,
|
||||
'valid_to': valid_to,
|
||||
'description': description,
|
||||
'price': price,
|
||||
'genres': genres
|
||||
}
|
||||
|
||||
def _fetchRSS() -> list:
|
||||
try:
|
||||
feed = feedparser.parse(RSS_URL)
|
||||
if feed.bozo:
|
||||
logging.warning(f"Erreur de parsing RSS: {feed.bozo_exception}")
|
||||
return feed.entries
|
||||
except Exception as e:
|
||||
logging.error(f"Échec de la récupération du flux RSS: {e}")
|
||||
return []
|
||||
|
||||
def _isSourceEnabled(source: str) -> bool:
|
||||
helper = ConfigurationHelper()
|
||||
enabled_sources = helper.getValue('freegames_sources')
|
||||
if not enabled_sources:
|
||||
return True
|
||||
enabled_list = [s.strip().lower() for s in enabled_sources.split(',')]
|
||||
return source.lower() in enabled_list or any(k in source.lower() for k in enabled_list)
|
||||
|
||||
def _getMentionText() -> str:
|
||||
helper = ConfigurationHelper()
|
||||
mention_type = helper.getValue('freegames_mention_type')
|
||||
|
||||
if mention_type == 'everyone':
|
||||
return '@everyone '
|
||||
elif mention_type == 'here':
|
||||
return '@here '
|
||||
elif mention_type == 'role':
|
||||
role_id = helper.getIntValue('freegames_mention_role')
|
||||
if role_id:
|
||||
return f'<@&{role_id}> '
|
||||
return ''
|
||||
|
||||
def _formatEmbed(game: FreeGame, parsed_data: dict) -> discord.Embed:
|
||||
clean_title = game.title
|
||||
if ' - ' in clean_title:
|
||||
clean_title = clean_title.split(' - ', 1)[1]
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"{clean_title} gratuit sur {game.source} !",
|
||||
url=game.url,
|
||||
color=discord.Color.green()
|
||||
)
|
||||
|
||||
if game.description:
|
||||
desc = game.description[:300] + '...' if len(game.description) > 300 else game.description
|
||||
embed.description = desc
|
||||
|
||||
price_line = ""
|
||||
if parsed_data.get('price'):
|
||||
price_line = f"~~{parsed_data['price']}~~ **Gratuit**"
|
||||
else:
|
||||
price_line = "**Gratuit**"
|
||||
|
||||
if game.valid_to:
|
||||
price_line += f" jusqu'au {game.valid_to.strftime('%d/%m/%Y')}"
|
||||
|
||||
embed.add_field(name="💰 Prix", value=price_line, inline=False)
|
||||
embed.add_field(name="🔗 Récupérer le jeu", value=f"[Ouvrir dans la boutique !]({game.url})", inline=False)
|
||||
|
||||
if game.image_url:
|
||||
embed.set_image(url=game.image_url)
|
||||
|
||||
embed.set_footer(text="🎁 Jeu à looter - Mamie Henriette")
|
||||
|
||||
return embed
|
||||
|
||||
def fetchAndStoreGames() -> list[FreeGame]:
|
||||
entries = _fetchRSS()
|
||||
new_games = []
|
||||
|
||||
for entry in entries:
|
||||
entry_id = entry.get('id', entry.get('link', ''))
|
||||
|
||||
existing = FreeGame.query.filter_by(entry_id=entry_id).first()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
title = entry.get('title', 'Jeu inconnu')
|
||||
source = _parseSource(title)
|
||||
url = entry.get('link', '')
|
||||
|
||||
parsed = _parseEntryContent(entry)
|
||||
|
||||
game = FreeGame(
|
||||
entry_id=entry_id,
|
||||
title=title,
|
||||
source=source,
|
||||
url=url,
|
||||
image_url=parsed.get('image_url'),
|
||||
description=parsed.get('description'),
|
||||
valid_from=parsed.get('valid_from'),
|
||||
valid_to=parsed.get('valid_to'),
|
||||
notified=False,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
db.session.add(game)
|
||||
new_games.append(game)
|
||||
|
||||
if new_games:
|
||||
db.session.commit()
|
||||
logging.info(f"{len(new_games)} nouveaux jeux gratuits ajoutés")
|
||||
|
||||
return new_games
|
||||
|
||||
def getPendingGames() -> list[FreeGame]:
|
||||
return FreeGame.query.filter_by(notified=False).order_by(FreeGame.created_at.desc()).all()
|
||||
|
||||
def getAllGames() -> list[FreeGame]:
|
||||
return FreeGame.query.order_by(FreeGame.created_at.desc()).all()
|
||||
|
||||
async def notifyGame(bot: Client, game_id: int) -> bool:
|
||||
if not _isEnabled():
|
||||
return False
|
||||
|
||||
game = FreeGame.query.get(game_id)
|
||||
if not game or game.notified:
|
||||
return False
|
||||
|
||||
helper = ConfigurationHelper()
|
||||
channel_id = helper.getIntValue('freegames_channel')
|
||||
channel = bot.get_channel(channel_id)
|
||||
|
||||
if not channel:
|
||||
logging.error(f"Canal Free Games {channel_id} introuvable")
|
||||
return False
|
||||
|
||||
try:
|
||||
parsed = _parseEntryContent({'content': [{'value': game.description or ''}]})
|
||||
embed = _formatEmbed(game, parsed)
|
||||
mention = _getMentionText()
|
||||
|
||||
await channel.send(content=mention if mention else None, embed=embed)
|
||||
|
||||
game.notified = True
|
||||
game.notified_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
|
||||
logging.info(f"Notification envoyée pour: {game.title}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Échec de l'envoi de la notification Free Games: {e}")
|
||||
return False
|
||||
|
||||
async def checkFreeGamesAndNotify(bot: Client):
|
||||
if not _isEnabled():
|
||||
logging.debug('Free Games est désactivé')
|
||||
return
|
||||
|
||||
helper = ConfigurationHelper()
|
||||
auto_notify = helper.getValue('freegames_auto_notify')
|
||||
|
||||
new_games = fetchAndStoreGames()
|
||||
|
||||
if auto_notify:
|
||||
for game in new_games:
|
||||
if _isSourceEnabled(game.source):
|
||||
await notifyGame(bot, game.id)
|
||||
|
||||
def markAsNotified(game_id: int) -> bool:
|
||||
game = FreeGame.query.get(game_id)
|
||||
if game:
|
||||
game.notified = True
|
||||
game.notified_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def resetNotification(game_id: int) -> bool:
|
||||
game = FreeGame.query.get(game_id)
|
||||
if game:
|
||||
game.notified = False
|
||||
game.notified_at = None
|
||||
db.session.commit()
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,232 @@
|
||||
# FreeLoot Discord : notifications depuis le feed LootScraper (jeux gratuits Epic, Amazon Prime, GOG, etc.)
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from discord import Client
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import FreeLootEntry
|
||||
from freeloot_feed import (
|
||||
SOURCES,
|
||||
fetch_feed,
|
||||
source_key_from_entry,
|
||||
game_name_from_title,
|
||||
extract_image_from_content,
|
||||
extract_description_from_content,
|
||||
extract_valid_to_from_content,
|
||||
extract_recommended_price_from_content,
|
||||
extract_genres_from_content,
|
||||
extract_rating_from_content,
|
||||
)
|
||||
|
||||
|
||||
def _get_mention_content() -> str:
|
||||
"""Construit le contenu du message (mentions) depuis la config."""
|
||||
raw = ConfigurationHelper().getValue("freeloot_mention")
|
||||
if not raw or not str(raw).strip():
|
||||
return ""
|
||||
parts = []
|
||||
for s in str(raw).strip().split(","):
|
||||
s = s.strip()
|
||||
if s == "everyone":
|
||||
parts.append("@everyone")
|
||||
elif s == "here":
|
||||
parts.append("@here")
|
||||
elif s.isdigit():
|
||||
parts.append(f"<@&{s}>")
|
||||
return " ".join(parts) if parts else ""
|
||||
|
||||
|
||||
def _is_enabled_source(source_key: str) -> bool:
|
||||
"""Vérifie si cette source est activée dans la config (freeloot_sources)."""
|
||||
raw = ConfigurationHelper().getValue("freeloot_sources")
|
||||
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
|
||||
return True
|
||||
enabled = [s.strip() for s in str(raw).split(",") if s.strip()]
|
||||
return source_key in enabled if enabled else True
|
||||
|
||||
|
||||
def _store_label_for_title(source_key: str) -> str:
|
||||
"""Libellé court pour le titre style DraftBot (ex: 'l'Epic Games Store')."""
|
||||
labels = {
|
||||
"epic_pc": "l'Epic Games Store",
|
||||
"epic_android": "l'Epic Games Store (Android)",
|
||||
"epic_ios": "l'Epic Games Store (iOS)",
|
||||
"amazon_prime": "Amazon Prime Gaming",
|
||||
"gog": "GOG",
|
||||
"google_play": "Google Play",
|
||||
"apple_app_store": "l'App Store",
|
||||
}
|
||||
return labels.get(source_key, "la boutique")
|
||||
|
||||
|
||||
# Logo (thumbnail) de chaque boutique pour l'embed Discord (affiché en haut à droite)
|
||||
SOURCE_LOGO_URLS = {
|
||||
"epic_pc": "https://store.epicgames.com/favicon.ico",
|
||||
"epic_android": "https://store.epicgames.com/favicon.ico",
|
||||
"epic_ios": "https://store.epicgames.com/favicon.ico",
|
||||
"amazon_prime": "https://gaming.amazon.com/favicon.ico",
|
||||
"gog": "https://www.gog.com/favicon.ico",
|
||||
"google_play": "https://play.google.com/favicon.ico",
|
||||
"apple_app_store": "https://www.apple.com/favicon.ico",
|
||||
}
|
||||
|
||||
|
||||
def _build_embed(entry: dict, source_key: str):
|
||||
import discord
|
||||
game_name = game_name_from_title(entry["title"])
|
||||
source_label = next((s[1] for s in SOURCES if s[0] == source_key), source_key)
|
||||
link = entry.get("link") or ""
|
||||
content_raw = entry.get("content") or ""
|
||||
img_url = extract_image_from_content(content_raw)
|
||||
description = extract_description_from_content(content_raw, max_len=350)
|
||||
valid_to = extract_valid_to_from_content(content_raw)
|
||||
store_title = _store_label_for_title(source_key)
|
||||
# Couleur barre gauche style DraftBot (orange-rouge)
|
||||
color = 0xE67E22
|
||||
title = f"{game_name} gratuit sur {store_title} !"
|
||||
embed = discord.Embed(
|
||||
title=title,
|
||||
url=link if link.startswith("http") else None,
|
||||
color=color,
|
||||
)
|
||||
if description:
|
||||
embed.description = description
|
||||
# Prix / gratuit / validité (Discord : pas de couleur dans le texte, seulement **gras** / markdown)
|
||||
value_parts = ["**Gratuit**"]
|
||||
if valid_to:
|
||||
try:
|
||||
from datetime import datetime
|
||||
end = datetime.fromisoformat(valid_to.replace("Z", "+00:00"))
|
||||
value_parts.append(f"jusqu'au {end.strftime('%d/%m/%Y')}")
|
||||
except Exception:
|
||||
value_parts.append(f"jusqu'au {valid_to[:10]}")
|
||||
embed.add_field(
|
||||
name="Prix",
|
||||
value=" • ".join(value_parts),
|
||||
inline=False,
|
||||
)
|
||||
recommended_price = extract_recommended_price_from_content(content_raw)
|
||||
if recommended_price:
|
||||
embed.add_field(name="Prix recommandé", value=recommended_price, inline=True)
|
||||
genres = extract_genres_from_content(content_raw)
|
||||
if genres:
|
||||
embed.add_field(name="Genres", value=genres, inline=True)
|
||||
rating = extract_rating_from_content(content_raw)
|
||||
if rating:
|
||||
embed.add_field(name="Ratings", value=rating, inline=True)
|
||||
if link and link.startswith("http"):
|
||||
embed.add_field(
|
||||
name="\u200b",
|
||||
value=f"[Ouvrir dans la boutique !]({link})",
|
||||
inline=False,
|
||||
)
|
||||
# Thumbnail (logo de la boutique en haut à droite)
|
||||
logo_url = SOURCE_LOGO_URLS.get(source_key)
|
||||
if logo_url and logo_url.startswith("http"):
|
||||
embed.set_thumbnail(url=logo_url)
|
||||
# Image principale (style DraftBot)
|
||||
if img_url and img_url.startswith("http"):
|
||||
embed.set_image(url=img_url)
|
||||
embed.set_footer(text="MamieHenriette • FreeLoot")
|
||||
return embed
|
||||
|
||||
|
||||
_freeloot_first_check = True
|
||||
|
||||
async def checkFreeLootAndNotify(bot: Client):
|
||||
global _freeloot_first_check
|
||||
helper = ConfigurationHelper()
|
||||
if not helper.getValue("freeloot_enable"):
|
||||
return
|
||||
channel_id = helper.getIntValue("freeloot_channel_id")
|
||||
if not channel_id:
|
||||
return
|
||||
channel = bot.get_channel(channel_id)
|
||||
if not channel:
|
||||
logging.warning("FreeLoot: canal Discord introuvable")
|
||||
return
|
||||
entries = fetch_feed()
|
||||
if not entries:
|
||||
return
|
||||
|
||||
# Au premier check après le démarrage, on synchronise sans notifier
|
||||
if _freeloot_first_check:
|
||||
logging.info("FreeLoot: première vérification, synchronisation sans notification")
|
||||
for entry in entries:
|
||||
entry_id = entry["id"]
|
||||
if not FreeLootEntry.query.get(entry_id):
|
||||
source_key = source_key_from_entry(entry["title"], entry["link"])
|
||||
if source_key and _is_enabled_source(source_key):
|
||||
try:
|
||||
db.session.add(FreeLootEntry(entry_id=entry_id))
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
logging.error(f"FreeLoot: erreur de synchronisation pour {entry_id}: {e}")
|
||||
db.session.rollback()
|
||||
_freeloot_first_check = False
|
||||
return
|
||||
|
||||
# Vérifications suivantes : notification normale
|
||||
for entry in entries:
|
||||
entry_id = entry["id"]
|
||||
if FreeLootEntry.query.get(entry_id):
|
||||
continue
|
||||
source_key = source_key_from_entry(entry["title"], entry["link"])
|
||||
if not source_key or not _is_enabled_source(source_key):
|
||||
continue
|
||||
try:
|
||||
embed = _build_embed(entry, source_key)
|
||||
content = _get_mention_content()
|
||||
await channel.send(content=content or None, embed=embed)
|
||||
db.session.add(FreeLootEntry(entry_id=entry_id))
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
logging.error(f"FreeLoot: envoi Discord échoué pour {entry_id}: {e}")
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
async def _send_entry_to_discord_async(bot: Client, entry_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Envoie une entrée FreeLoot sur Discord (appel manuel). Retourne (succès, message).
|
||||
"""
|
||||
channel_id = ConfigurationHelper().getIntValue("freeloot_channel_id")
|
||||
if not channel_id:
|
||||
return (False, "Aucun canal Discord configuré pour FreeLoot.")
|
||||
channel = bot.get_channel(channel_id)
|
||||
if not channel:
|
||||
return (False, "Canal Discord introuvable.")
|
||||
entries = fetch_feed()
|
||||
if not entries:
|
||||
return (False, "Impossible de charger le flux.")
|
||||
entry = next((e for e in entries if e.get("id") == entry_id), None)
|
||||
if not entry:
|
||||
return (False, "Entrée introuvable dans le flux.")
|
||||
source_key = source_key_from_entry(entry["title"], entry["link"])
|
||||
if not source_key:
|
||||
return (False, "Source non reconnue pour cette entrée.")
|
||||
try:
|
||||
embed = _build_embed(entry, source_key)
|
||||
content = _get_mention_content()
|
||||
await channel.send(content=content or None, embed=embed)
|
||||
if not FreeLootEntry.query.get(entry_id):
|
||||
db.session.add(FreeLootEntry(entry_id=entry_id))
|
||||
db.session.commit()
|
||||
return (True, "Annonce envoyée sur Discord.")
|
||||
except Exception as e:
|
||||
logging.error(f"FreeLoot: envoi manuel échoué pour {entry_id}: {e}")
|
||||
db.session.rollback()
|
||||
return (False, str(e))
|
||||
|
||||
|
||||
def send_entry_to_discord_sync(bot: Client, entry_id: str) -> tuple[bool, str]:
|
||||
"""Appel synchrone pour envoyer une entrée sur Discord (depuis la webapp)."""
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
_send_entry_to_discord_async(bot, entry_id),
|
||||
bot.loop,
|
||||
)
|
||||
return future.result(timeout=15)
|
||||
except Exception as e:
|
||||
logging.error(f"FreeLoot: send_entry_to_discord_sync: {e}")
|
||||
return (False, str(e))
|
||||
@@ -8,6 +8,8 @@ from database.helpers import ConfigurationHelper
|
||||
from database.models import GameBundle
|
||||
from discord import Client
|
||||
|
||||
_humblebundle_first_check = True
|
||||
|
||||
|
||||
def _isEnable():
|
||||
helper = ConfigurationHelper()
|
||||
@@ -40,10 +42,22 @@ def _formatMessage(bundle):
|
||||
return message
|
||||
|
||||
async def checkHumbleBundleAndNotify(bot: Client):
|
||||
global _humblebundle_first_check
|
||||
if _isEnable() :
|
||||
try :
|
||||
bundles = _callGithub()
|
||||
bundle = _findFirstNotNotified(bundles)
|
||||
|
||||
# Premier check : synchronisation sans notification
|
||||
if _humblebundle_first_check:
|
||||
if bundle != None:
|
||||
logging.info(f'HumbleBundle: première vérification, synchronisation sans notification pour {bundle["name"]}')
|
||||
db.session.add(GameBundle(url=bundle['url'], name=bundle['name'], json = json.dumps(bundle)))
|
||||
db.session.commit()
|
||||
_humblebundle_first_check = False
|
||||
return
|
||||
|
||||
# Vérifications normales ensuite
|
||||
if bundle != None :
|
||||
message = _formatMessage(bundle)
|
||||
await bot.get_channel(ConfigurationHelper().getIntValue('humble_bundle_channel')).send(message)
|
||||
|
||||
@@ -1220,7 +1220,7 @@ async def parse_target_user(message: Message, bot, parts: list):
|
||||
except (ValueError, discord.NotFound):
|
||||
return None
|
||||
|
||||
def create_inspect_embed(user, member, join_date, days_on_server, account_age, warnings, kicks, bans, invite_info, requester):
|
||||
def create_inspect_embed(user, member, join_date, days_on_server, account_age, warnings, kicks, bans, invite_info):
|
||||
embed = discord.Embed(
|
||||
title=f"🔍 Inspection de {user.name}",
|
||||
color=discord.Color.blue(),
|
||||
@@ -1228,7 +1228,7 @@ def create_inspect_embed(user, member, join_date, days_on_server, account_age, w
|
||||
)
|
||||
|
||||
embed.set_thumbnail(url=user.display_avatar.url)
|
||||
embed.add_field(name="👤 Utilisateur", value=f"**{user.name}**\n```{user.id}```", inline=True)
|
||||
embed.add_field(name="👤 Utilisateur", value=f"**{user.name}**\n`{user.id}`", inline=True)
|
||||
|
||||
if account_age is not None:
|
||||
embed.add_field(
|
||||
@@ -1281,7 +1281,7 @@ def create_inspect_embed(user, member, join_date, days_on_server, account_age, w
|
||||
else:
|
||||
embed.add_field(name="✅ Historique de modération", value="Aucun incident", inline=False)
|
||||
|
||||
embed.set_footer(text=f"Demandé par {requester.name}")
|
||||
embed.set_footer(text="Mamie Henriette")
|
||||
return embed
|
||||
|
||||
async def get_invite_info_for_user(bot, guild, user_id: int):
|
||||
@@ -1339,8 +1339,7 @@ async def handle_inspect_command(message: Message, bot):
|
||||
warnings,
|
||||
kicks,
|
||||
bans,
|
||||
invite_info,
|
||||
message.author
|
||||
invite_info
|
||||
)
|
||||
|
||||
await message.channel.send(embed=embed)
|
||||
|
||||
@@ -87,8 +87,6 @@ async def sendWelcomeMessage(bot: discord.Client, member: Member):
|
||||
except Exception as e:
|
||||
logging.error(f'Échec de la sauvegarde de l\'invitation : {e}')
|
||||
|
||||
account_age_days = (datetime.now(timezone.utc) - member.created_at).days
|
||||
|
||||
embed = discord.Embed(
|
||||
title='🎉 Nouveau membre !',
|
||||
description=welcome_message,
|
||||
@@ -104,8 +102,12 @@ async def sendWelcomeMessage(bot: discord.Client, member: Member):
|
||||
try:
|
||||
message = await channel.send(embed=embed)
|
||||
logging.info(f'Message de bienvenue envoyé pour {member.name}')
|
||||
if account_age_days < 7:
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
account_age = (now - member.created_at).days
|
||||
if account_age < 7:
|
||||
await message.add_reaction('⚠️')
|
||||
logging.info(f'Réaction warning ajoutée pour {member.name} (compte créé il y a {account_age} jours)')
|
||||
except Exception as e:
|
||||
logging.error(f'Échec de l\'envoi du message de bienvenue : {e}')
|
||||
|
||||
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,200 @@
|
||||
# Module partagé : récupération et parsing du flux LootScraper (sans dépendance Discord)
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from html import unescape
|
||||
|
||||
import requests
|
||||
|
||||
FEED_URL = "https://feed.eikowagenknecht.com/lootscraper.xml"
|
||||
ATOM_NS = {"atom": "http://www.w3.org/2005/Atom"}
|
||||
|
||||
SOURCES = [
|
||||
("epic_pc", "Epic Games (PC)", "🖥️"),
|
||||
("epic_android", "Epic Games (Android)", "🤖"),
|
||||
("epic_ios", "Epic Games (iOS)", "🍎"),
|
||||
("amazon_prime", "Amazon Prime Gaming", "📦"),
|
||||
("gog", "GOG", "🎮"),
|
||||
("google_play", "Google Play", "🤖"),
|
||||
("apple_app_store", "Apple App Store", "🍎"),
|
||||
]
|
||||
|
||||
|
||||
def source_key_from_entry(title: str, link: str) -> str | None:
|
||||
"""Détermine la clé source+plateforme depuis le titre et le lien."""
|
||||
title_upper = (title or "").upper()
|
||||
link_lower = (link or "").lower()
|
||||
if "EPIC GAMES" in title_upper:
|
||||
if "-ios-" in link_lower or "/ios-" in link_lower:
|
||||
return "epic_ios"
|
||||
if "-android-" in link_lower or "/android-" in link_lower:
|
||||
return "epic_android"
|
||||
return "epic_pc"
|
||||
if "AMAZON PRIME" in title_upper:
|
||||
return "amazon_prime"
|
||||
if "GOG" in title_upper:
|
||||
return "gog"
|
||||
if "GOOGLE PLAY" in title_upper:
|
||||
return "google_play"
|
||||
if "APPLE APP STORE" in title_upper:
|
||||
return "apple_app_store"
|
||||
return None
|
||||
|
||||
|
||||
def game_name_from_title(title: str) -> str:
|
||||
"""Extrait le nom du jeu depuis le titre."""
|
||||
if not title:
|
||||
return "Jeu gratuit"
|
||||
for prefix in (
|
||||
"Epic Games (Game) - ",
|
||||
"Amazon Prime (Game) - ",
|
||||
"GOG (Game) - ",
|
||||
"GOG (Game, Always Free) - ",
|
||||
"Google Play (Game) - ",
|
||||
"Apple App Store (Game) - ",
|
||||
):
|
||||
if title.startswith(prefix):
|
||||
return unescape(title[len(prefix) :].strip())
|
||||
if " - " in title:
|
||||
return unescape(title.split(" - ", 1)[1].strip())
|
||||
return unescape(title.strip())
|
||||
|
||||
|
||||
def extract_image_from_content(content: str) -> str | None:
|
||||
"""Extrait l'URL de la première image du contenu HTML (toutes balises img)."""
|
||||
if not content:
|
||||
return None
|
||||
|
||||
def _normalize_url(url: str) -> str:
|
||||
u = unescape(url.strip()).replace("&", "&")
|
||||
return u
|
||||
|
||||
# 1) Balises <img ... src="url" ...>
|
||||
for m in re.finditer(r'<img[^>]+src\s*=\s*["\']([^"\']+)["\']', content, re.I):
|
||||
url = _normalize_url(m.group(1))
|
||||
if url.startswith("http"):
|
||||
return url
|
||||
# 2) Fallback: toute attribution src="http..." (certains CDN n'ont pas d'extension)
|
||||
for m in re.finditer(r'src\s*=\s*["\'](https?://[^"\']{20,})["\']', content, re.I):
|
||||
url = _normalize_url(m.group(1))
|
||||
if url.startswith("http"):
|
||||
return url
|
||||
return None
|
||||
|
||||
|
||||
def extract_description_from_content(content: str, max_len: int = 400) -> str | None:
|
||||
"""Extrait la description du jeu depuis le contenu HTML (balise <b>Description:</b>)."""
|
||||
if not content:
|
||||
return None
|
||||
m = re.search(r"<b>Description:</b>\s*([^<]+)", content, re.I | re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
desc = unescape(m.group(1).strip())
|
||||
desc = re.sub(r"\s+", " ", desc)
|
||||
if len(desc) > max_len:
|
||||
desc = desc[: max_len - 3].rsplit(" ", 1)[0] + "..."
|
||||
return desc or None
|
||||
|
||||
|
||||
def extract_valid_to_from_content(content: str) -> str | None:
|
||||
"""Extrait la date 'Offer valid to' depuis le contenu HTML."""
|
||||
if not content:
|
||||
return None
|
||||
m = re.search(r"<b>Offer valid to:</b>\s*(\d{4}-\d{2}-\d{2}[^<]*)", content, re.I)
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
|
||||
def extract_recommended_price_from_content(content: str) -> str | None:
|
||||
"""Extrait le prix recommandé (ex: '39.99 EUR') depuis le contenu HTML."""
|
||||
if not content:
|
||||
return None
|
||||
m = re.search(r"<b>Recommended price\s*\([^)]*\):\s*</b>\s*([^<]+)", content, re.I)
|
||||
if not m:
|
||||
m = re.search(r"Recommended price[^<]*</b>\s*([^<]+)", content, re.I)
|
||||
return unescape(m.group(1).strip()) if m else None
|
||||
|
||||
|
||||
def extract_genres_from_content(content: str) -> str | None:
|
||||
"""Extrait les genres (ex: 'Action, Indie') depuis le contenu HTML."""
|
||||
if not content:
|
||||
return None
|
||||
m = re.search(r"<b>Genres:</b>\s*([^<]+)", content, re.I)
|
||||
return unescape(m.group(1).strip()) if m else None
|
||||
|
||||
|
||||
def extract_rating_from_content(content: str) -> str | None:
|
||||
"""Extrait le rating (texte après Ratings:, liens ou texte brut)."""
|
||||
if not content:
|
||||
return None
|
||||
m = re.search(r"<b>Ratings:</b>\s*(.+?)</li>", content, re.I | re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
raw = m.group(1)
|
||||
raw = re.sub(r"<a[^>]*>([^<]*)</a>", r"\1", raw)
|
||||
raw = re.sub(r"<[^>]+>", " ", raw)
|
||||
raw = unescape(re.sub(r"\s+", " ", raw).strip())
|
||||
return raw if len(raw) > 0 and len(raw) < 200 else None
|
||||
|
||||
|
||||
def fetch_feed() -> list[dict] | None:
|
||||
"""Récupère et parse le flux Atom, retourne une liste d'entrées brutes."""
|
||||
try:
|
||||
r = requests.get(FEED_URL, timeout=15)
|
||||
r.raise_for_status()
|
||||
root = ET.fromstring(r.content)
|
||||
entries = []
|
||||
for entry_el in root.findall(".//atom:entry", ATOM_NS):
|
||||
entry_id_el = entry_el.find("atom:id", ATOM_NS)
|
||||
title_el = entry_el.find("atom:title", ATOM_NS)
|
||||
link_el = entry_el.find("atom:link", ATOM_NS)
|
||||
content_el = entry_el.find("atom:content", ATOM_NS)
|
||||
updated_el = entry_el.find("atom:updated", ATOM_NS) or entry_el.find("atom:published", ATOM_NS)
|
||||
entry_id = entry_id_el.text.strip() if entry_id_el is not None and entry_id_el.text else None
|
||||
title = title_el.text.strip() if title_el is not None and title_el.text else None
|
||||
link = link_el.get("href") if link_el is not None else None
|
||||
content = ""
|
||||
if content_el is not None:
|
||||
# Sérialiser tout le sous-arbre pour ne rien perdre (notamment les <img>)
|
||||
content = ET.tostring(content_el, encoding="unicode", method="xml")
|
||||
# Normaliser les préfixes de namespace (ex: <html:b> -> <b>) pour que les regex d'extraction matchent
|
||||
content = content.replace("</html:", "</").replace("<html:", "<")
|
||||
updated = updated_el.text.strip() if updated_el is not None and updated_el.text else None
|
||||
if entry_id and title:
|
||||
entries.append({
|
||||
"id": entry_id,
|
||||
"title": title,
|
||||
"link": link or "",
|
||||
"content": content or "",
|
||||
"updated": updated,
|
||||
})
|
||||
return entries
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_display_entries() -> list[dict]:
|
||||
"""Retourne la liste des entrées formatées pour affichage (webapp)."""
|
||||
raw = fetch_feed()
|
||||
if not raw:
|
||||
return []
|
||||
result = []
|
||||
for e in raw:
|
||||
sk = source_key_from_entry(e["title"], e["link"])
|
||||
content = e.get("content") or ""
|
||||
desc = extract_description_from_content(content)
|
||||
result.append({
|
||||
"id": e["id"],
|
||||
"title": e["title"],
|
||||
"link": e["link"],
|
||||
"game_name": game_name_from_title(e["title"]),
|
||||
"source_key": sk or "other",
|
||||
"source_label": next((s[1] for s in SOURCES if s[0] == sk), sk or "Autre"),
|
||||
"emoji": next((s[2] for s in SOURCES if s[0] == sk), "🎁"),
|
||||
"image_url": extract_image_from_content(content),
|
||||
"description": desc,
|
||||
"valid_to": extract_valid_to_from_content(content),
|
||||
"recommended_price": extract_recommended_price_from_content(content),
|
||||
"genres": extract_genres_from_content(content),
|
||||
"rating": extract_rating_from_content(content),
|
||||
"updated": e.get("updated"),
|
||||
})
|
||||
return result
|
||||
+1
-3
@@ -11,6 +11,7 @@ twitchAPI>=4.5.0
|
||||
|
||||
# Nécessaire pour l'hébergement du site web
|
||||
flask>=2.3.2
|
||||
flask-login>=0.6.3
|
||||
flask-sqlalchemy>=3.0.3
|
||||
flask[async]
|
||||
waitress>=3.0.2
|
||||
@@ -21,6 +22,3 @@ requests>=2.32.4
|
||||
# Nécessaire pour l'appel à ProtonDB
|
||||
algoliasearch>=4,<5
|
||||
|
||||
# Nécessaire pour le flux RSS des jeux gratuits
|
||||
feedparser>=6.0.0
|
||||
|
||||
|
||||
-241
@@ -1,241 +0,0 @@
|
||||
|
||||
import threading
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Callable
|
||||
from datetime import datetime
|
||||
|
||||
@dataclass
|
||||
class BotStats:
|
||||
"""Statistiques des bots"""
|
||||
# Discord
|
||||
discord_connected: bool = False
|
||||
discord_guilds: int = 0
|
||||
discord_members: int = 0
|
||||
discord_channels: int = 0
|
||||
discord_bot_name: str = ""
|
||||
discord_bot_id: int = 0
|
||||
|
||||
# Twitch
|
||||
twitch_connected: bool = False
|
||||
twitch_channel: str = ""
|
||||
|
||||
# Cogs/Fonctionnalités activées
|
||||
cogs_enabled: Dict[str, bool] = field(default_factory=dict)
|
||||
|
||||
# Dernière mise à jour
|
||||
last_update: datetime = None
|
||||
|
||||
class StatsManager:
|
||||
"""Gestionnaire thread-safe des statistiques"""
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._stats = BotStats()
|
||||
cls._instance._stats_lock = threading.Lock()
|
||||
return cls._instance
|
||||
|
||||
def update_discord_stats(self, connected: bool = None, guilds: int = None,
|
||||
members: int = None, channels: int = None,
|
||||
bot_name: str = None, bot_id: int = None):
|
||||
"""Met à jour les stats Discord"""
|
||||
with self._stats_lock:
|
||||
if connected is not None:
|
||||
self._stats.discord_connected = connected
|
||||
if guilds is not None:
|
||||
self._stats.discord_guilds = guilds
|
||||
if members is not None:
|
||||
self._stats.discord_members = members
|
||||
if channels is not None:
|
||||
self._stats.discord_channels = channels
|
||||
if bot_name is not None:
|
||||
self._stats.discord_bot_name = bot_name
|
||||
if bot_id is not None:
|
||||
self._stats.discord_bot_id = bot_id
|
||||
self._stats.last_update = datetime.now()
|
||||
|
||||
def update_twitch_stats(self, connected: bool = None, channel: str = None):
|
||||
"""Met à jour les stats Twitch"""
|
||||
with self._stats_lock:
|
||||
if connected is not None:
|
||||
self._stats.twitch_connected = connected
|
||||
if channel is not None:
|
||||
self._stats.twitch_channel = channel
|
||||
self._stats.last_update = datetime.now()
|
||||
|
||||
def update_cogs(self, cogs: Dict[str, bool]):
|
||||
"""Met à jour les cogs activés"""
|
||||
with self._stats_lock:
|
||||
self._stats.cogs_enabled = cogs.copy()
|
||||
self._stats.last_update = datetime.now()
|
||||
|
||||
def get_stats(self) -> BotStats:
|
||||
"""Retourne une copie des stats"""
|
||||
with self._stats_lock:
|
||||
return BotStats(
|
||||
discord_connected=self._stats.discord_connected,
|
||||
discord_guilds=self._stats.discord_guilds,
|
||||
discord_members=self._stats.discord_members,
|
||||
discord_channels=self._stats.discord_channels,
|
||||
discord_bot_name=self._stats.discord_bot_name,
|
||||
discord_bot_id=self._stats.discord_bot_id,
|
||||
twitch_connected=self._stats.twitch_connected,
|
||||
twitch_channel=self._stats.twitch_channel,
|
||||
cogs_enabled=self._stats.cogs_enabled.copy(),
|
||||
last_update=self._stats.last_update
|
||||
)
|
||||
|
||||
|
||||
class DiscordBridge:
|
||||
"""
|
||||
Pont de communication entre Flask et le bot Discord.
|
||||
Permet d'exécuter des actions Discord depuis Flask de manière thread-safe.
|
||||
"""
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._bot = None
|
||||
cls._instance._loop = None
|
||||
return cls._instance
|
||||
|
||||
def register_bot(self, bot, loop):
|
||||
"""Enregistre le bot Discord et sa boucle événementielle"""
|
||||
with self._lock:
|
||||
self._bot = bot
|
||||
self._loop = loop
|
||||
logging.info("Bot Discord enregistré dans le bridge")
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
"""Vérifie si le bot est prêt"""
|
||||
return self._bot is not None and self._loop is not None and not self._loop.is_closed()
|
||||
|
||||
def get_text_channels(self) -> List[Dict]:
|
||||
"""Retourne la liste des canaux texte disponibles"""
|
||||
if not self.is_ready():
|
||||
return []
|
||||
|
||||
channels = []
|
||||
try:
|
||||
for guild in self._bot.guilds:
|
||||
for channel in guild.text_channels:
|
||||
channels.append({
|
||||
'id': channel.id,
|
||||
'name': channel.name,
|
||||
'guild_name': guild.name,
|
||||
'guild_id': guild.id
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur lors de la récupération des canaux: {e}")
|
||||
|
||||
return channels
|
||||
|
||||
def send_message(self, channel_id: int, message: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Envoie un message dans un canal Discord.
|
||||
Retourne (succès, message d'erreur ou de confirmation)
|
||||
"""
|
||||
if not self.is_ready():
|
||||
return False, "Le bot Discord n'est pas connecté"
|
||||
|
||||
if not message or not message.strip():
|
||||
return False, "Le message ne peut pas être vide"
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._send_message_async(channel_id, message),
|
||||
self._loop
|
||||
)
|
||||
# Attendre le résultat avec timeout de 10 secondes
|
||||
return future.result(timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
return False, "Timeout lors de l'envoi du message"
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur lors de l'envoi du message: {e}")
|
||||
return False, f"Erreur: {str(e)}"
|
||||
|
||||
async def _send_message_async(self, channel_id: int, message: str) -> tuple[bool, str]:
|
||||
"""Coroutine interne pour envoyer le message"""
|
||||
try:
|
||||
channel = self._bot.get_channel(channel_id)
|
||||
if not channel:
|
||||
return False, "Canal introuvable"
|
||||
|
||||
await channel.send(message)
|
||||
return True, f"Message envoyé dans #{channel.name}"
|
||||
except Exception as e:
|
||||
return False, f"Erreur: {str(e)}"
|
||||
|
||||
def sync_invites(self, guild_id: int = None) -> dict:
|
||||
"""Synchronise les invitations Discord avec la base de données"""
|
||||
if not self.is_ready():
|
||||
return {'success': False, 'message': "Le bot Discord n'est pas connecté", 'synced': 0}
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._bot.syncInvites(guild_id),
|
||||
self._loop
|
||||
)
|
||||
result = future.result(timeout=30)
|
||||
result['success'] = True
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
return {'success': False, 'message': "Timeout lors de la synchronisation", 'synced': 0}
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur lors de la synchronisation des invitations: {e}")
|
||||
return {'success': False, 'message': f"Erreur: {str(e)}", 'synced': 0}
|
||||
|
||||
def revoke_invite(self, invite_code: str) -> dict:
|
||||
"""Révoque une invitation Discord"""
|
||||
if not self.is_ready():
|
||||
return {'success': False, 'message': "Le bot Discord n'est pas connecté"}
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._bot.revokeInvite(invite_code),
|
||||
self._loop
|
||||
)
|
||||
return future.result(timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
return {'success': False, 'message': "Timeout lors de la révocation"}
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur lors de la révocation de l'invitation: {e}")
|
||||
return {'success': False, 'message': f"Erreur: {str(e)}"}
|
||||
|
||||
def get_invites(self, guild_id: int = None, include_revoked: bool = False) -> list:
|
||||
"""Récupère les invitations depuis la base de données"""
|
||||
if not self.is_ready():
|
||||
return []
|
||||
|
||||
try:
|
||||
return self._bot.getInvites(guild_id, include_revoked)
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur lors de la récupération des invitations: {e}")
|
||||
return []
|
||||
|
||||
def get_guilds(self) -> list:
|
||||
"""Retourne la liste des guilds"""
|
||||
if not self.is_ready():
|
||||
return []
|
||||
|
||||
try:
|
||||
return self._bot.getAllGuilds()
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur lors de la récupération des guilds: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# Instances globales
|
||||
stats_manager = StatsManager()
|
||||
discord_bridge = DiscordBridge()
|
||||
|
||||
+214
-28
@@ -1,4 +1,3 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
@@ -7,69 +6,256 @@ from twitchAPI.type import AuthScope, ChatEvent
|
||||
from twitchAPI.chat import Chat, ChatEvent, ChatMessage, EventData
|
||||
|
||||
from database.helpers import ConfigurationHelper
|
||||
from twitchbot.live_alert import checkOnlineStreamer
|
||||
from webapp import webapp
|
||||
from shared_stats import stats_manager
|
||||
from database.models import Commande
|
||||
|
||||
|
||||
def _user_has_twitch_permission(msg: ChatMessage, required: str) -> bool:
|
||||
"""Vérifie si l'utilisateur a le niveau de permission requis (viewer, sub, vip, moderator)."""
|
||||
if not required or required == 'viewer':
|
||||
return True
|
||||
is_broadcaster = msg.user.name.lower() == msg.room.name.lower()
|
||||
is_mod = msg.user.mod or is_broadcaster
|
||||
if required == 'moderator':
|
||||
return is_mod
|
||||
if required == 'vip':
|
||||
return msg.user.vip or is_mod
|
||||
if required == 'sub':
|
||||
return msg.user.subscriber or msg.user.vip or is_mod
|
||||
return True
|
||||
from twitchbot.live_alert import checkOnlineStreamer
|
||||
from twitchbot.announcements import checkAndSendAnnouncements, incrementMessageCount
|
||||
from twitchbot import moderation
|
||||
from twitchbot import link_filter
|
||||
from twitchbot import event_notifications
|
||||
from webapp import webapp
|
||||
|
||||
USER_SCOPE = [
|
||||
AuthScope.CHAT_READ,
|
||||
AuthScope.CHAT_EDIT,
|
||||
AuthScope.MODERATOR_MANAGE_BANNED_USERS,
|
||||
AuthScope.MODERATOR_MANAGE_CHAT_MESSAGES,
|
||||
AuthScope.MODERATOR_MANAGE_CHAT_SETTINGS,
|
||||
AuthScope.MODERATOR_MANAGE_SHIELD_MODE,
|
||||
AuthScope.CHANNEL_MANAGE_BROADCAST,
|
||||
AuthScope.MODERATOR_READ_FOLLOWERS,
|
||||
AuthScope.CHANNEL_READ_SUBSCRIPTIONS, # EventSub channel.subscribe (notifs sub)
|
||||
]
|
||||
|
||||
USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT]
|
||||
|
||||
async def _onReady(ready_event: EventData):
|
||||
logging.info('Bot Twitch prêt')
|
||||
with webapp.app_context():
|
||||
channel = ConfigurationHelper().getValue('twitch_channel')
|
||||
webapp.config["BOT_STATUS"]["twitch_connected"] = True
|
||||
webapp.config["BOT_STATUS"]["twitch_channel_name"] = channel
|
||||
await ready_event.chat.join_room(channel)
|
||||
stats_manager.update_twitch_stats(connected=True, channel=channel)
|
||||
# EventSub (follow, sub, raid) : besoin du broadcaster_id
|
||||
try:
|
||||
from twitchAPI.helper import first
|
||||
user = await first(twitchBot.twitch.get_users(logins=[channel]))
|
||||
if user:
|
||||
twitchBot._eventsub = event_notifications.create_eventsub(twitchBot.twitch, asyncio.get_event_loop())
|
||||
asyncio.create_task(event_notifications.register_eventsub_handlers(twitchBot._eventsub, user.id, ready_event.chat, channel))
|
||||
except Exception as e:
|
||||
logging.warning('EventSub non démarré: %s', e)
|
||||
asyncio.get_event_loop().create_task(twitchBot._checkOnlineStreamers())
|
||||
|
||||
asyncio.get_event_loop().create_task(twitchBot._runAnnouncements())
|
||||
asyncio.get_event_loop().create_task(twitchBot._checkClips())
|
||||
|
||||
|
||||
async def _onMessage(msg: ChatMessage):
|
||||
logging.info(f'Dans {msg.room.name}, {msg.user.name} a dit : {msg.text}')
|
||||
incrementMessageCount()
|
||||
|
||||
# Stocker le message dans BOT_STATUS pour l'affichage web
|
||||
with webapp.app_context():
|
||||
from datetime import datetime
|
||||
message_data = {
|
||||
'username': msg.user.name,
|
||||
'text': msg.text,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'is_mod': msg.user.mod,
|
||||
'is_subscriber': msg.user.subscriber,
|
||||
'is_vip': msg.user.vip,
|
||||
'color': getattr(msg.user, 'color', None) or '#9146FF'
|
||||
}
|
||||
messages = webapp.config["BOT_STATUS"]["twitch_chat_messages"]
|
||||
messages.append(message_data)
|
||||
# Limiter à 100 messages
|
||||
if len(messages) > 100:
|
||||
messages.pop(0)
|
||||
|
||||
if not await link_filter.check_message_for_links(msg, twitchBot.twitch):
|
||||
return
|
||||
if not await moderation.check_message_for_banned_words(msg, twitchBot.twitch):
|
||||
return
|
||||
await _handleCustomCommand(msg)
|
||||
|
||||
|
||||
async def _handleCustomCommand(msg: ChatMessage):
|
||||
if not msg.text.startswith('!'):
|
||||
return
|
||||
trigger = msg.text.split()[0].lower()
|
||||
with webapp.app_context():
|
||||
# Vérifier si les commandes Twitch sont activées globalement
|
||||
if not ConfigurationHelper().getValue('twitch_commands_enable'):
|
||||
return
|
||||
commande = Commande.query.filter_by(trigger=trigger, twitch_enable=True).first()
|
||||
if commande:
|
||||
permission = commande.twitch_permission or 'viewer'
|
||||
if not _user_has_twitch_permission(msg, permission):
|
||||
return # Pas de réponse = l'utilisateur n'a pas la permission
|
||||
response = commande.response.replace('{user}', msg.user.name)
|
||||
await msg.reply(response)
|
||||
|
||||
|
||||
# commande qui répond "bonjour" à "!hello"
|
||||
async def _helloCommand(msg: ChatMessage):
|
||||
await msg.reply(f'Bonjour {msg.user.name}')
|
||||
|
||||
def _isConfigured() -> bool:
|
||||
helper = ConfigurationHelper()
|
||||
return helper.getValue('twitch_client_id') != None and helper.getValue('twitch_client_secret') != None and helper.getValue('twitch_access_token') != None and helper.getValue('twitch_refresh_token') != None and helper.getValue('twitch_channel') != None
|
||||
|
||||
class TwitchBot() :
|
||||
def _isConfigured():
|
||||
helper = ConfigurationHelper()
|
||||
return (helper.getValue('twitch_client_id') is not None and
|
||||
helper.getValue('twitch_client_secret') is not None and
|
||||
helper.getValue('twitch_access_token') is not None and
|
||||
helper.getValue('twitch_refresh_token') is not None and
|
||||
helper.getValue('twitch_channel') is not None)
|
||||
|
||||
|
||||
class TwitchBot():
|
||||
_eventsub = None
|
||||
|
||||
async def _connect(self):
|
||||
with webapp.app_context():
|
||||
if _isConfigured() :
|
||||
try :
|
||||
if _isConfigured():
|
||||
try:
|
||||
helper = ConfigurationHelper()
|
||||
self.twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
|
||||
await self.twitch.set_user_authentication(helper.getValue('twitch_access_token'), USER_SCOPE, helper.getValue('twitch_refresh_token'))
|
||||
self.chat = await Chat(self.twitch)
|
||||
self.chat.register_event(ChatEvent.READY, _onReady)
|
||||
self.chat.register_event(ChatEvent.MESSAGE, _onMessage)
|
||||
# chat.register_event(ChatEvent.SUB, on_sub)
|
||||
self.chat.register_command('hello', _helloCommand)
|
||||
self._register_moderation_commands()
|
||||
self.chat.start()
|
||||
except Exception as e:
|
||||
logging.error(f'Échec de l\'authentification Twitch. Vérifiez vos identifiants et redémarrez après correction : {e}')
|
||||
else:
|
||||
except Exception as e:
|
||||
logging.error(f'Échec de l\'authentification Twitch : {e}')
|
||||
else:
|
||||
logging.info("Twitch n'est pas configuré")
|
||||
|
||||
async def _checkOnlineStreamers(self):
|
||||
# pas bon faudrait faire un truc mieux
|
||||
while True :
|
||||
|
||||
def _register_moderation_commands(self):
|
||||
# Créer des wrappers async pour chaque commande
|
||||
async def cmd_timeout(msg): await moderation.timeout_command(msg, self.twitch)
|
||||
async def cmd_ban(msg): await moderation.ban_command(msg, self.twitch)
|
||||
async def cmd_unban(msg): await moderation.unban_command(msg, self.twitch)
|
||||
async def cmd_clean(msg): await moderation.clean_command(msg, self.twitch)
|
||||
async def cmd_shieldmode(msg): await moderation.shieldmode_command(msg, self.twitch)
|
||||
async def cmd_settitle(msg): await moderation.settitle_command(msg, self.twitch)
|
||||
async def cmd_setgame(msg): await moderation.setgame_command(msg, self.twitch)
|
||||
async def cmd_subon(msg): await moderation.subon_command(msg, self.twitch)
|
||||
async def cmd_suboff(msg): await moderation.suboff_command(msg, self.twitch)
|
||||
async def cmd_follon(msg): await moderation.follon_command(msg, self.twitch)
|
||||
async def cmd_folloff(msg): await moderation.folloff_command(msg, self.twitch)
|
||||
async def cmd_emoteon(msg): await moderation.emoteon_command(msg, self.twitch)
|
||||
async def cmd_emoteoff(msg): await moderation.emoteoff_command(msg, self.twitch)
|
||||
async def cmd_ann(msg): await moderation.ann_command(msg, self.twitch)
|
||||
async def cmd_no_game(msg): await moderation.no_game_command(msg, self.twitch)
|
||||
async def cmd_multitwitch(msg): await moderation.multitwitch_command(msg, self.twitch)
|
||||
async def cmd_permit(msg): await link_filter.permit_command(msg, self.twitch)
|
||||
|
||||
self.chat.register_command('kick', cmd_timeout)
|
||||
self.chat.register_command('to', cmd_timeout)
|
||||
self.chat.register_command('timeout', cmd_timeout)
|
||||
self.chat.register_command('tm', cmd_timeout)
|
||||
self.chat.register_command('ban', cmd_ban)
|
||||
self.chat.register_command('unban', cmd_unban)
|
||||
self.chat.register_command('clean', cmd_clean)
|
||||
self.chat.register_command('shieldmode', cmd_shieldmode)
|
||||
self.chat.register_command('settitle', cmd_settitle)
|
||||
self.chat.register_command('setgame', cmd_setgame)
|
||||
self.chat.register_command('setcateg', cmd_setgame)
|
||||
self.chat.register_command('subon', cmd_subon)
|
||||
self.chat.register_command('suboff', cmd_suboff)
|
||||
self.chat.register_command('follon', cmd_follon)
|
||||
self.chat.register_command('folloff', cmd_folloff)
|
||||
self.chat.register_command('emoteon', cmd_emoteon)
|
||||
self.chat.register_command('emoteoff', cmd_emoteoff)
|
||||
self.chat.register_command('ann', cmd_ann)
|
||||
self.chat.register_command('no_game', cmd_no_game)
|
||||
self.chat.register_command('multitwitch', cmd_multitwitch)
|
||||
self.chat.register_command('permit', cmd_permit)
|
||||
|
||||
async def _checkOnlineStreamers(self):
|
||||
while True:
|
||||
try:
|
||||
await checkOnlineStreamer(self.twitch)
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur lors lors du check des streamers online : {e}')
|
||||
# toutes les 5 minutes
|
||||
await asyncio.sleep(5*60)
|
||||
logging.error(f'Erreur check streamers online : {e}')
|
||||
await asyncio.sleep(5 * 60)
|
||||
|
||||
def begin(self):
|
||||
async def _runAnnouncements(self):
|
||||
with webapp.app_context():
|
||||
channel = ConfigurationHelper().getValue('twitch_channel')
|
||||
while True:
|
||||
try:
|
||||
await checkAndSendAnnouncements(self.chat, channel, self.twitch)
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur envoi annonces : {e}')
|
||||
await asyncio.sleep(2 * 60) # Vérification toutes les 2 min pour limiter le spam
|
||||
|
||||
async def _checkClips(self):
|
||||
"""Vérifie le clip le plus récent (polling) et notifie si nouveau."""
|
||||
with webapp.app_context():
|
||||
channel = ConfigurationHelper().getValue('twitch_channel')
|
||||
if not channel:
|
||||
return
|
||||
from twitchAPI.helper import first
|
||||
try:
|
||||
user = await first(self.twitch.get_users(logins=[channel]))
|
||||
if not user:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
with webapp.app_context():
|
||||
from database.models import TwitchEventNotification
|
||||
cfg = TwitchEventNotification.query.filter_by(event_type='clip', enable=True).first()
|
||||
if not cfg:
|
||||
await asyncio.sleep(120)
|
||||
continue
|
||||
clip = await first(self.twitch.get_clips(broadcaster_id=user.id, first=1))
|
||||
if not clip:
|
||||
await asyncio.sleep(120)
|
||||
continue
|
||||
if cfg.last_clip_id is None:
|
||||
with webapp.app_context():
|
||||
from database import db
|
||||
cfg = TwitchEventNotification.query.filter_by(event_type='clip', enable=True).first()
|
||||
if cfg:
|
||||
cfg.last_clip_id = clip.id
|
||||
db.session.commit()
|
||||
elif clip.id != cfg.last_clip_id:
|
||||
with webapp.app_context():
|
||||
await event_notifications.notify_clip(
|
||||
self.chat,
|
||||
channel,
|
||||
user=getattr(clip, 'creator_name', None) or getattr(clip, 'user_name', '') or clip.id,
|
||||
title=getattr(clip, 'title', '') or 'Clip',
|
||||
url=getattr(clip, 'url', '') or f'https://clips.twitch.tv/{clip.id}',
|
||||
thumbnail_url=getattr(clip, 'thumbnail_url', '') or '',
|
||||
clip_id=clip.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error('Erreur check clips: %s', e)
|
||||
await asyncio.sleep(120)
|
||||
|
||||
def begin(self):
|
||||
asyncio.run(self._connect())
|
||||
|
||||
# je ne sais pas encore comment appeler ça
|
||||
async def _close(self):
|
||||
self.chat.stop()
|
||||
await self.twitch.close()
|
||||
|
||||
twitchBot = TwitchBot()
|
||||
|
||||
twitchBot = TwitchBot()
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,264 @@
|
||||
# Notifications d'événements Twitch (sub, follow, raid, clip) : chat + Discord
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import discord
|
||||
from twitchAPI.chat import Chat
|
||||
from twitchAPI.eventsub.websocket import EventSubWebsocket
|
||||
from twitchAPI.object.eventsub import (
|
||||
ChannelFollowEvent,
|
||||
ChannelRaidEvent,
|
||||
ChannelSubscribeEvent,
|
||||
)
|
||||
from twitchAPI.twitch import Twitch
|
||||
|
||||
from database import db
|
||||
from database.models import TwitchEventNotification
|
||||
from webapp import webapp
|
||||
|
||||
logger = logging.getLogger("twitch-events")
|
||||
|
||||
|
||||
def _format_message(template: str, **kwargs: Any) -> str:
|
||||
if not template:
|
||||
return ""
|
||||
for k, v in (kwargs or {}).items():
|
||||
template = template.replace("{" + k + "}", str(v or ""))
|
||||
return template
|
||||
|
||||
|
||||
async def _send_twitch(chat: Chat, channel: str, text: str) -> None:
|
||||
if not text or not chat:
|
||||
return
|
||||
try:
|
||||
await chat.send_message(channel, text[:500])
|
||||
except Exception as e:
|
||||
logger.error("Envoi chat Twitch événement: %s", e)
|
||||
|
||||
|
||||
def _schedule_discord_send(channel_id: int, content: str | None, embed: discord.Embed | None) -> None:
|
||||
"""Planifie l'envoi sur le canal Discord (sans bloquer le loop Twitch)."""
|
||||
from discordbot import bot
|
||||
try:
|
||||
ch = bot.get_channel(channel_id)
|
||||
if not ch:
|
||||
logger.warning("Canal Discord %s introuvable", channel_id)
|
||||
return
|
||||
payload = content if content else embed
|
||||
if not payload:
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
ch.send(content=content, embed=embed) if (content and embed) else ch.send(content=content or None, embed=embed if not content else None),
|
||||
bot.loop,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Envoi Discord événement: %s", e)
|
||||
|
||||
|
||||
async def _handle_follow(data: ChannelFollowEvent, chat: Chat, channel: str) -> None:
|
||||
with webapp.app_context():
|
||||
cfg = TwitchEventNotification.query.filter_by(event_type="follow", enable=True).first()
|
||||
if not cfg:
|
||||
return
|
||||
ev = data.event
|
||||
user = getattr(ev, "user_name", None) or getattr(ev, "user_login", "")
|
||||
user_login = getattr(ev, "user_login", user)
|
||||
msg = _format_message(
|
||||
cfg.message_twitch or "Merci {user} pour le follow !",
|
||||
user=user_login,
|
||||
user_name=user,
|
||||
)
|
||||
if cfg.notify_twitch_chat and msg:
|
||||
await _send_twitch(chat, channel, msg)
|
||||
if cfg.notify_discord and cfg.discord_channel_id:
|
||||
content = _format_message(cfg.message_discord or "", user=user_login, user_name=user)
|
||||
try:
|
||||
embed_color = int(cfg.embed_color or "9146FF", 16)
|
||||
except ValueError:
|
||||
embed_color = 0x9146FF
|
||||
embed = discord.Embed(
|
||||
title=_format_message(cfg.embed_title or "Nouveau follow", user=user_login, user_name=user),
|
||||
description=cfg.embed_description or f"{user} suit maintenant la chaîne.",
|
||||
color=embed_color,
|
||||
)
|
||||
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
|
||||
|
||||
|
||||
async def _handle_subscribe(data: ChannelSubscribeEvent, chat: Chat, channel: str) -> None:
|
||||
with webapp.app_context():
|
||||
cfg = TwitchEventNotification.query.filter_by(event_type="sub", enable=True).first()
|
||||
if not cfg:
|
||||
return
|
||||
ev = data.event
|
||||
user = getattr(ev, "user_name", None) or getattr(ev, "user_login", "")
|
||||
user_login = getattr(ev, "user_login", user)
|
||||
msg = _format_message(
|
||||
cfg.message_twitch or "Merci {user} pour l'abonnement !",
|
||||
user=user_login,
|
||||
user_name=user,
|
||||
)
|
||||
if cfg.notify_twitch_chat and msg:
|
||||
await _send_twitch(chat, channel, msg)
|
||||
if cfg.notify_discord and cfg.discord_channel_id:
|
||||
content = _format_message(cfg.message_discord or "", user=user_login, user_name=user)
|
||||
try:
|
||||
embed_color = int(cfg.embed_color or "9146FF", 16)
|
||||
except ValueError:
|
||||
embed_color = 0x9146FF
|
||||
embed = discord.Embed(
|
||||
title=_format_message(cfg.embed_title or "Nouvel abonnement", user=user_login, user_name=user),
|
||||
description=cfg.embed_description or f"Merci à {user} pour l'abonnement !",
|
||||
color=embed_color,
|
||||
)
|
||||
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
|
||||
|
||||
|
||||
async def _handle_raid(data: ChannelRaidEvent, chat: Chat, channel: str) -> None:
|
||||
with webapp.app_context():
|
||||
cfg = TwitchEventNotification.query.filter_by(event_type="raid", enable=True).first()
|
||||
if not cfg:
|
||||
return
|
||||
ev = data.event
|
||||
from_broadcaster = getattr(ev, "from_broadcaster_user_name", None) or getattr(ev, "from_broadcaster_user_login", "")
|
||||
viewers = getattr(ev, "viewers", 0)
|
||||
msg = _format_message(
|
||||
cfg.message_twitch or "Bienvenue aux {viewers} viewers de {from_broadcaster_name} !",
|
||||
from_broadcaster_name=from_broadcaster,
|
||||
viewers=viewers,
|
||||
)
|
||||
if cfg.notify_twitch_chat and msg:
|
||||
await _send_twitch(chat, channel, msg)
|
||||
if cfg.notify_discord and cfg.discord_channel_id:
|
||||
content = _format_message(
|
||||
cfg.message_discord or "",
|
||||
from_broadcaster_name=from_broadcaster,
|
||||
viewers=viewers,
|
||||
)
|
||||
try:
|
||||
embed_color = int(cfg.embed_color or "9146FF", 16)
|
||||
except ValueError:
|
||||
embed_color = 0x9146FF
|
||||
embed = discord.Embed(
|
||||
title=_format_message(
|
||||
cfg.embed_title or "Raid reçu",
|
||||
from_broadcaster_name=from_broadcaster,
|
||||
viewers=viewers,
|
||||
),
|
||||
description=cfg.embed_description or f"{from_broadcaster} a raid avec {viewers} viewers !",
|
||||
color=embed_color,
|
||||
)
|
||||
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
|
||||
|
||||
|
||||
async def notify_clip(
|
||||
chat: Chat | None,
|
||||
channel: str,
|
||||
*,
|
||||
user: str,
|
||||
title: str,
|
||||
url: str,
|
||||
thumbnail_url: str,
|
||||
clip_id: str,
|
||||
) -> None:
|
||||
"""Appelé quand un nouveau clip est détecté (polling)."""
|
||||
with webapp.app_context():
|
||||
cfg = TwitchEventNotification.query.filter_by(event_type="clip", enable=True).first()
|
||||
if not cfg:
|
||||
return
|
||||
msg_twitch = _format_message(
|
||||
cfg.message_twitch or "Nouveau clip par {user} : {title} {url}",
|
||||
user=user,
|
||||
title=title,
|
||||
url=url,
|
||||
)
|
||||
if cfg.notify_twitch_chat and chat and msg_twitch:
|
||||
await _send_twitch(chat, channel, msg_twitch)
|
||||
if cfg.notify_discord and cfg.discord_channel_id:
|
||||
content = _format_message(
|
||||
cfg.message_discord or "",
|
||||
user=user,
|
||||
title=title,
|
||||
url=url,
|
||||
thumbnail_url=thumbnail_url or "",
|
||||
)
|
||||
try:
|
||||
embed_color = int(cfg.embed_color or "9146FF", 16)
|
||||
except ValueError:
|
||||
embed_color = 0x9146FF
|
||||
embed = discord.Embed(
|
||||
title=_format_message(cfg.embed_title or "Nouveau clip", user=user, title=title),
|
||||
url=url,
|
||||
description=cfg.embed_description or title,
|
||||
color=embed_color,
|
||||
)
|
||||
if cfg.embed_thumbnail and thumbnail_url:
|
||||
embed.set_thumbnail(url=thumbnail_url)
|
||||
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
|
||||
cfg.last_clip_id = clip_id
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def create_eventsub(twitch: Twitch, callback_loop: asyncio.AbstractEventLoop) -> EventSubWebsocket:
|
||||
"""Crée et démarre le client EventSub. Les callbacks seront exécutés sur `callback_loop`."""
|
||||
eventsub = EventSubWebsocket(twitch, callback_loop=callback_loop)
|
||||
eventsub.start()
|
||||
return eventsub
|
||||
|
||||
|
||||
async def register_eventsub_handlers(
|
||||
eventsub: EventSubWebsocket,
|
||||
broadcaster_id: str,
|
||||
chat: Chat,
|
||||
channel: str,
|
||||
) -> None:
|
||||
"""Enregistre follow, sub, raid sur l'EventSub. À appeler dans les 10 s après start()."""
|
||||
|
||||
# Définir les callbacks comme des wrappers explicites
|
||||
async def on_follow(data: ChannelFollowEvent) -> None:
|
||||
try:
|
||||
await _handle_follow(data, chat, channel)
|
||||
except Exception as e:
|
||||
logger.error("Erreur handler follow: %s", e)
|
||||
|
||||
async def on_subscribe(data: ChannelSubscribeEvent) -> None:
|
||||
try:
|
||||
await _handle_subscribe(data, chat, channel)
|
||||
except Exception as e:
|
||||
logger.error("Erreur handler subscribe: %s", e)
|
||||
|
||||
async def on_raid(data: ChannelRaidEvent) -> None:
|
||||
try:
|
||||
await _handle_raid(data, chat, channel)
|
||||
except Exception as e:
|
||||
logger.error("Erreur handler raid: %s", e)
|
||||
|
||||
# Chaque souscription est tentée séparément : si le token n'a pas channel:read:subscriptions,
|
||||
# seule "sub" échouera ; follow et raid restent actifs.
|
||||
subscriptions_ok = 0
|
||||
|
||||
try:
|
||||
await eventsub.listen_channel_follow_v2(broadcaster_id, broadcaster_id, on_follow)
|
||||
logger.info("EventSub: follow enregistré ✓")
|
||||
subscriptions_ok += 1
|
||||
except Exception as e:
|
||||
logger.error("EventSub follow: %s", e)
|
||||
|
||||
try:
|
||||
await eventsub.listen_channel_subscribe(broadcaster_id, on_subscribe)
|
||||
logger.info("EventSub: subscribe enregistré ✓")
|
||||
subscriptions_ok += 1
|
||||
except Exception as e:
|
||||
logger.warning("EventSub subscribe (nécessite scope channel:read:subscriptions): %s", e)
|
||||
|
||||
try:
|
||||
await eventsub.listen_channel_raid(to_broadcaster_user_id=broadcaster_id, callback=on_raid)
|
||||
logger.info("EventSub: raid enregistré ✓")
|
||||
subscriptions_ok += 1
|
||||
except Exception as e:
|
||||
logger.error("EventSub raid: %s", e)
|
||||
|
||||
if subscriptions_ok == 0:
|
||||
logger.error("EventSub: AUCUNE souscription n'a réussi ! Le WebSocket va se fermer.")
|
||||
else:
|
||||
logger.info(f"EventSub: {subscriptions_ok}/3 souscriptions actives")
|
||||
@@ -0,0 +1,166 @@
|
||||
import re
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from twitchAPI.twitch import Twitch
|
||||
from twitchAPI.chat import ChatMessage
|
||||
|
||||
from database import db
|
||||
from database.models import TwitchLinkFilter, TwitchAllowedDomain, TwitchPermit, TwitchAllowedUser
|
||||
from twitchbot.moderation import _log_action, _get_broadcaster_id, _get_moderator_id, _get_user_id, _is_moderator
|
||||
from webapp import webapp
|
||||
|
||||
logger = logging.getLogger('twitch-link-filter')
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
URL_REGEX = re.compile(r'https?://[^\s]+|(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,}(?:/[^\s]*)?')
|
||||
|
||||
|
||||
def _get_filter_config():
|
||||
"""Retourne un dictionnaire avec la config du filtre de liens"""
|
||||
with webapp.app_context():
|
||||
config = TwitchLinkFilter.query.first()
|
||||
if not config:
|
||||
config = TwitchLinkFilter(enabled=False)
|
||||
db.session.add(config)
|
||||
db.session.commit()
|
||||
# Retourner un dict pour éviter DetachedInstanceError
|
||||
return {
|
||||
'enabled': config.enabled,
|
||||
'allow_subscribers': config.allow_subscribers,
|
||||
'allow_vips': config.allow_vips,
|
||||
'allow_moderators': config.allow_moderators,
|
||||
'timeout_duration': config.timeout_duration,
|
||||
'warning_message': config.warning_message
|
||||
}
|
||||
|
||||
|
||||
def _get_allowed_domains():
|
||||
with webapp.app_context():
|
||||
return [d.domain.lower() for d in TwitchAllowedDomain.query.all()]
|
||||
|
||||
|
||||
def _is_user_whitelisted(username: str) -> bool:
|
||||
with webapp.app_context():
|
||||
return TwitchAllowedUser.query.filter_by(username=username.lower()).first() is not None
|
||||
|
||||
|
||||
def _has_valid_permit(username: str) -> bool:
|
||||
with webapp.app_context():
|
||||
permit = TwitchPermit.query.filter_by(username=username.lower()).first()
|
||||
if permit and permit.expires_at > datetime.now():
|
||||
return True
|
||||
if permit and permit.expires_at <= datetime.now():
|
||||
db.session.delete(permit)
|
||||
db.session.commit()
|
||||
return False
|
||||
|
||||
|
||||
def _extract_domain(url: str) -> str:
|
||||
url = url.lower()
|
||||
url = re.sub(r'^https?://', '', url)
|
||||
url = re.sub(r'^www\.', '', url)
|
||||
return url.split('/')[0]
|
||||
|
||||
|
||||
def _is_domain_allowed(url: str, allowed_domains: list) -> bool:
|
||||
domain = _extract_domain(url)
|
||||
for allowed in allowed_domains:
|
||||
if domain == allowed or domain.endswith('.' + allowed):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def check_message_for_links(msg: ChatMessage, twitch: Twitch) -> bool:
|
||||
config = _get_filter_config()
|
||||
|
||||
if not config['enabled']:
|
||||
return True
|
||||
|
||||
if config['allow_moderators'] and (msg.user.mod or msg.user.name.lower() == msg.room.name.lower()):
|
||||
return True
|
||||
|
||||
if config['allow_vips'] and msg.user.vip:
|
||||
return True
|
||||
|
||||
if config['allow_subscribers'] and msg.user.subscriber:
|
||||
return True
|
||||
|
||||
if _is_user_whitelisted(msg.user.name):
|
||||
return True
|
||||
|
||||
urls = URL_REGEX.findall(msg.text)
|
||||
if not urls:
|
||||
return True
|
||||
|
||||
if _has_valid_permit(msg.user.name):
|
||||
with webapp.app_context():
|
||||
permit = TwitchPermit.query.filter_by(username=msg.user.name.lower()).first()
|
||||
if permit:
|
||||
db.session.delete(permit)
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
allowed_domains = _get_allowed_domains()
|
||||
for url in urls:
|
||||
if not _is_domain_allowed(url, allowed_domains):
|
||||
await _handle_unauthorized_link(msg, twitch, config, url)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _handle_unauthorized_link(msg: ChatMessage, twitch: Twitch, config: dict, url: str):
|
||||
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
|
||||
moderator_id = await _get_moderator_id(twitch)
|
||||
user_id = await _get_user_id(twitch, msg.user.name)
|
||||
|
||||
if user_id and config['timeout_duration'] > 0:
|
||||
try:
|
||||
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason="Lien non autorise", duration=config['timeout_duration'])
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur timeout link filter: {e}")
|
||||
|
||||
try:
|
||||
await twitch.delete_chat_messages(broadcaster_id, moderator_id, message_id=msg.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur suppression message: {e}")
|
||||
|
||||
if config['warning_message']:
|
||||
await msg.reply(config['warning_message'])
|
||||
|
||||
_log_action("link_blocked", "AutoMod", msg.user.name, _extract_domain(url))
|
||||
logger.info(f"Lien bloque de {msg.user.name}: {url}")
|
||||
|
||||
|
||||
async def permit_command(msg: ChatMessage, twitch: Twitch):
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
args = msg.text.split()[1:]
|
||||
if len(args) < 1:
|
||||
await msg.reply("Usage: !permit <viewer> [minutes]")
|
||||
return
|
||||
|
||||
username = args[0].lstrip('@').lower()
|
||||
duration = 60
|
||||
if len(args) >= 2:
|
||||
try:
|
||||
duration = int(args[1]) * 60
|
||||
except ValueError:
|
||||
duration = 60
|
||||
|
||||
expires_at = datetime.now() + timedelta(seconds=duration)
|
||||
|
||||
with webapp.app_context():
|
||||
existing = TwitchPermit.query.filter_by(username=username).first()
|
||||
if existing:
|
||||
existing.expires_at = expires_at
|
||||
else:
|
||||
permit = TwitchPermit(username=username, expires_at=expires_at)
|
||||
db.session.add(permit)
|
||||
db.session.commit()
|
||||
|
||||
_log_action("permit", msg.user.name, username, f"{duration}s")
|
||||
await msg.reply(f"@{username} peut poster un lien pendant {duration // 60} minute(s)")
|
||||
logger.info(f"Permit accorde a {username} par {msg.user.name}")
|
||||
+155
-12
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import discord
|
||||
|
||||
from twitchAPI.twitch import Twitch
|
||||
from twitchAPI.object.api import Stream
|
||||
@@ -11,11 +12,76 @@ from webapp import webapp
|
||||
logger = logging.getLogger('live-alert')
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
_live_alert_first_check = True
|
||||
|
||||
|
||||
def _stream_thumbnail_url(stream: Stream) -> str:
|
||||
"""URL de la miniature du stream (preview Twitch)."""
|
||||
url = getattr(stream, 'thumbnail_url', None) or ''
|
||||
if '{width}' in url or '{height}' in url:
|
||||
url = url.replace('{width}', '320').replace('{height}', '180')
|
||||
if not url:
|
||||
url = f"https://static-cdn.jtvnw.net/previews-ttv/live_user_{stream.user_login}-320x180.jpg"
|
||||
return url
|
||||
|
||||
|
||||
def _format_embed_text(text: str, stream: Stream, stream_url: str, thumbnail: str) -> str:
|
||||
"""Formate un texte d'embed avec les variables stream."""
|
||||
if not text:
|
||||
return ''
|
||||
try:
|
||||
return text.format(
|
||||
user_login=stream.user_login or '',
|
||||
user_name=stream.user_name or '',
|
||||
game_name=getattr(stream, 'game_name', None) or '',
|
||||
title=stream.title or '',
|
||||
language=getattr(stream, 'language', None) or '',
|
||||
stream_url=stream_url,
|
||||
thumbnail=thumbnail,
|
||||
)
|
||||
except KeyError:
|
||||
return text
|
||||
|
||||
|
||||
async def checkOnlineStreamer(twitch: Twitch) :
|
||||
global _live_alert_first_check
|
||||
with webapp.app_context() :
|
||||
alerts : list[LiveAlert] = LiveAlert.query.all()
|
||||
streams = await _retreiveStreams(twitch, alerts)
|
||||
watch_stream = None
|
||||
|
||||
# Récupération du statut du live principal (channel configuré)
|
||||
from database.helpers import ConfigurationHelper
|
||||
main_channel = ConfigurationHelper().getValue('twitch_channel')
|
||||
main_stream = None
|
||||
if main_channel:
|
||||
main_stream = next((s for s in streams if s.user_login.lower() == main_channel.lower()), None)
|
||||
|
||||
# Mise à jour du BOT_STATUS pour la webapp
|
||||
if main_stream:
|
||||
webapp.config["BOT_STATUS"]["twitch_is_live"] = True
|
||||
webapp.config["BOT_STATUS"]["twitch_viewer_count"] = getattr(main_stream, 'viewer_count', 0)
|
||||
else:
|
||||
webapp.config["BOT_STATUS"]["twitch_is_live"] = False
|
||||
webapp.config["BOT_STATUS"]["twitch_viewer_count"] = 0
|
||||
|
||||
# Premier check : synchronisation sans notification
|
||||
if _live_alert_first_check:
|
||||
logger.info('Live Alert: première vérification, synchronisation sans notification')
|
||||
for alert in alerts:
|
||||
stream = next((s for s in streams if s.user_login == alert.login), None)
|
||||
if stream:
|
||||
alert.online = True
|
||||
if alert.watch_activity and alert.enable:
|
||||
watch_stream = stream
|
||||
else:
|
||||
alert.online = False
|
||||
await _updateBotActivity(watch_stream)
|
||||
db.session.commit()
|
||||
_live_alert_first_check = False
|
||||
return
|
||||
|
||||
# Vérifications normales ensuite
|
||||
for alert in alerts :
|
||||
stream = next((s for s in streams if s.user_login == alert.login), None)
|
||||
if stream :
|
||||
@@ -24,26 +90,103 @@ async def checkOnlineStreamer(twitch: Twitch) :
|
||||
logger.info(f'N\'etait pas en ligne auparavant : {alert.login}')
|
||||
await _notifyAlert(alert, stream)
|
||||
alert.online = True
|
||||
if alert.watch_activity and alert.enable:
|
||||
watch_stream = stream
|
||||
else :
|
||||
logger.info(f'Streamer hors ligne : {alert.login}')
|
||||
alert.online = False
|
||||
|
||||
await _updateBotActivity(watch_stream)
|
||||
db.session.commit()
|
||||
|
||||
async def _notifyAlert(alert : LiveAlert, stream : Stream):
|
||||
message : str = alert.message.format(stream)
|
||||
logger.info(f'Message de notification : {message}')
|
||||
bot.loop.create_task(_sendMessage(alert.notify_channel, message))
|
||||
|
||||
async def _sendMessage(channel : int, message : str) :
|
||||
logger.info(f'Envoi de notification : {message}')
|
||||
await bot.get_channel(channel).send(message)
|
||||
logger.info(f'Notification envoyé')
|
||||
async def _updateBotActivity(stream: Stream | None):
|
||||
if stream:
|
||||
logger.info(f'Mise à jour de l\'activité : Regarde le live de {stream.user_name}')
|
||||
activity = discord.Streaming(
|
||||
name=f'Regarde le live de {stream.user_name}',
|
||||
url=f'https://www.twitch.tv/{stream.user_login}'
|
||||
)
|
||||
await bot.change_presence(status=discord.Status.online, activity=activity)
|
||||
else:
|
||||
logger.info('Aucun stream à regarder, retour à l\'activité normale')
|
||||
# Remettre une humeur aléatoire
|
||||
from database.models import Humeur
|
||||
import random
|
||||
humeurs = Humeur.query.all()
|
||||
if humeurs:
|
||||
humeur = random.choice(humeurs)
|
||||
logger.info(f'Réinitialisation du statut : {humeur.text}')
|
||||
await bot.change_presence(status=discord.Status.online, activity=discord.CustomActivity(humeur.text))
|
||||
else:
|
||||
# Si pas de humeur, remettre un statut par défaut
|
||||
await bot.change_presence(status=discord.Status.online, activity=None)
|
||||
|
||||
async def _retreiveStreams(twitch: Twitch, alerts : list[LiveAlert]) -> list[Stream] :
|
||||
streams : list[Stream] = []
|
||||
async def _notifyAlert(alert: LiveAlert, stream: Stream):
|
||||
stream_url = f'https://www.twitch.tv/{stream.user_login}'
|
||||
thumbnail = _stream_thumbnail_url(stream)
|
||||
|
||||
# Message texte optionnel (avant l'embed)
|
||||
message_text = None
|
||||
if alert.message and alert.message.strip():
|
||||
try:
|
||||
message_text = alert.message.format(stream)
|
||||
except KeyError:
|
||||
message_text = alert.message
|
||||
|
||||
# Construction de l'embed Discord
|
||||
try:
|
||||
embed_color = int(alert.embed_color or '9146FF', 16)
|
||||
except ValueError:
|
||||
embed_color = 0x9146FF
|
||||
|
||||
embed_title = _format_embed_text(alert.embed_title, stream, stream_url, thumbnail) if alert.embed_title else (stream.title or f"{stream.user_name} est en live")
|
||||
embed_description = _format_embed_text(alert.embed_description, stream, stream_url, thumbnail) if alert.embed_description else None
|
||||
|
||||
embed = discord.Embed(
|
||||
title=embed_title,
|
||||
url=stream_url,
|
||||
color=embed_color
|
||||
)
|
||||
if embed_description:
|
||||
embed.description = embed_description
|
||||
|
||||
author_name = _format_embed_text(alert.embed_author_name, stream, stream_url, thumbnail) if alert.embed_author_name else stream.user_name
|
||||
user_id = getattr(stream, 'user_id', None)
|
||||
author_icon = alert.embed_author_icon or (f"https://static-cdn.jtvnw.net/jtv_user_pictures/{user_id}-profile_image-70x70.png" if user_id else "https://static-cdn.jtvnw.net/ttv-favicon/favicon-32x32.png")
|
||||
embed.set_author(name=author_name, icon_url=author_icon)
|
||||
|
||||
if alert.embed_thumbnail and thumbnail:
|
||||
embed.set_thumbnail(url=thumbnail)
|
||||
if alert.embed_image and thumbnail:
|
||||
embed.set_image(url=thumbnail)
|
||||
|
||||
if alert.embed_footer:
|
||||
footer_text = _format_embed_text(alert.embed_footer, stream, stream_url, thumbnail)
|
||||
if footer_text:
|
||||
embed.set_footer(text=footer_text)
|
||||
|
||||
logger.info(f'Envoi de notification live (embed) : {stream.user_login}')
|
||||
bot.loop.create_task(_sendMessage(alert.notify_channel, message_text, embed))
|
||||
|
||||
async def _sendMessage(channel_id: int, message: str | None, embed: discord.Embed):
|
||||
try:
|
||||
discord_channel = bot.get_channel(channel_id)
|
||||
if not discord_channel:
|
||||
logger.error(f"Canal Discord {channel_id} introuvable")
|
||||
return
|
||||
if message and message.strip():
|
||||
await discord_channel.send(content=message, embed=embed)
|
||||
else:
|
||||
await discord_channel.send(embed=embed)
|
||||
logger.info('Notification live envoyée')
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de l'envoi de la notification live : {e}")
|
||||
|
||||
async def _retreiveStreams(twitch: Twitch, alerts: list[LiveAlert]) -> list[Stream]:
|
||||
streams: list[Stream] = []
|
||||
logger.info(f'Recherche de streams pour : {alerts}')
|
||||
async for stream in twitch.get_streams(user_login = [alert.login for alert in alerts]):
|
||||
async for stream in twitch.get_streams(user_login=[alert.login for alert in alerts]):
|
||||
streams.append(stream)
|
||||
logger.info(f'Ces streams sont en ligne : {streams}')
|
||||
return streams
|
||||
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
import re
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from twitchAPI.twitch import Twitch
|
||||
from twitchAPI.chat import ChatMessage
|
||||
|
||||
from database import db
|
||||
from database.models import TwitchAnnouncement, TwitchModerationLog, TwitchBannedWord
|
||||
from webapp import webapp
|
||||
|
||||
logger = logging.getLogger('twitch-moderation')
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
last_multitwitch: str = None
|
||||
games_disabled: bool = False
|
||||
|
||||
|
||||
def _log_action(action: str, moderator: str, target: str = None, details: str = None):
|
||||
with webapp.app_context():
|
||||
log = TwitchModerationLog(
|
||||
action=action,
|
||||
moderator=moderator,
|
||||
target=target,
|
||||
details=details,
|
||||
created_at=datetime.now()
|
||||
)
|
||||
db.session.add(log)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _is_moderator(msg: ChatMessage) -> bool:
|
||||
return msg.user.mod or msg.user.name.lower() == msg.room.name.lower()
|
||||
|
||||
|
||||
async def _get_broadcaster_id(twitch: Twitch, channel: str) -> str:
|
||||
async for user in twitch.get_users(logins=[channel]):
|
||||
return user.id
|
||||
return None
|
||||
|
||||
|
||||
async def _get_user_id(twitch: Twitch, username: str) -> str:
|
||||
async for user in twitch.get_users(logins=[username]):
|
||||
return user.id
|
||||
return None
|
||||
|
||||
|
||||
async def _get_moderator_id(twitch: Twitch) -> str:
|
||||
async for user in twitch.get_users():
|
||||
return user.id
|
||||
return None
|
||||
|
||||
|
||||
async def timeout_command(msg: ChatMessage, twitch: Twitch):
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
args = msg.text.split()[1:]
|
||||
if len(args) < 1:
|
||||
await msg.reply("Usage: !timeout <viewer> [minutes] [raison]")
|
||||
return
|
||||
|
||||
viewer = args[0].lstrip('@')
|
||||
duration = 180 # 3 minutes par défaut
|
||||
reason = "Timeout"
|
||||
|
||||
# Si args[1] est un nombre, c'est la durée, sinon c'est la raison
|
||||
if len(args) >= 2:
|
||||
try:
|
||||
duration = int(args[1]) * 60
|
||||
# Tout ce qui suit est la raison
|
||||
if len(args) >= 3:
|
||||
reason = ' '.join(args[2:])
|
||||
except ValueError:
|
||||
# args[1] n'est pas un nombre, donc tout depuis args[1] est la raison
|
||||
reason = ' '.join(args[1:])
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
|
||||
moderator_id = await _get_moderator_id(twitch)
|
||||
user_id = await _get_user_id(twitch, viewer)
|
||||
|
||||
if user_id:
|
||||
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason, duration=duration)
|
||||
# Log avec durée et raison
|
||||
log_details = f"{duration}s - {reason}"
|
||||
_log_action("timeout", msg.user.name, viewer, log_details)
|
||||
logger.info(f'{viewer} timeout pour {duration}s par {msg.user.name} - Raison: {reason}')
|
||||
|
||||
|
||||
async def ban_command(msg: ChatMessage, twitch: Twitch):
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
args = msg.text.split()[1:]
|
||||
if len(args) < 1:
|
||||
await msg.reply("Usage: !ban <viewer1> [viewer2] ...")
|
||||
return
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
|
||||
moderator_id = await _get_moderator_id(twitch)
|
||||
|
||||
for viewer in args[:5]:
|
||||
viewer = viewer.lstrip('@')
|
||||
user_id = await _get_user_id(twitch, viewer)
|
||||
if user_id:
|
||||
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason="Ban")
|
||||
_log_action("ban", msg.user.name, viewer)
|
||||
logger.info(f'{viewer} banni par {msg.user.name}')
|
||||
|
||||
|
||||
async def unban_command(msg: ChatMessage, twitch: Twitch):
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
args = msg.text.split()[1:]
|
||||
if len(args) < 1:
|
||||
await msg.reply("Usage: !unban <viewer1> [viewer2] ...")
|
||||
return
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
|
||||
moderator_id = await _get_moderator_id(twitch)
|
||||
|
||||
for viewer in args[:5]:
|
||||
viewer = viewer.lstrip('@')
|
||||
user_id = await _get_user_id(twitch, viewer)
|
||||
if user_id:
|
||||
await twitch.unban_user(broadcaster_id, moderator_id, user_id)
|
||||
_log_action("unban", msg.user.name, viewer)
|
||||
logger.info(f'{viewer} débanni par {msg.user.name}')
|
||||
|
||||
|
||||
async def clean_command(msg: ChatMessage, twitch: Twitch):
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
args = msg.text.split()[1:]
|
||||
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
|
||||
moderator_id = await _get_moderator_id(twitch)
|
||||
|
||||
if len(args) >= 1:
|
||||
viewer = args[0].lstrip('@')
|
||||
user_id = await _get_user_id(twitch, viewer)
|
||||
if user_id:
|
||||
await twitch.delete_chat_messages(broadcaster_id, moderator_id, user_id=user_id)
|
||||
_log_action("clean", msg.user.name, viewer)
|
||||
logger.info(f'Messages de {viewer} supprimés par {msg.user.name}')
|
||||
else:
|
||||
await twitch.delete_chat_messages(broadcaster_id, moderator_id)
|
||||
_log_action("clean", msg.user.name, None, "Chat complet")
|
||||
logger.info(f'Chat nettoyé par {msg.user.name}')
|
||||
|
||||
|
||||
async def shieldmode_command(msg: ChatMessage, twitch: Twitch):
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
args = msg.text.split()[1:]
|
||||
if len(args) < 1:
|
||||
await msg.reply("Usage: !shieldmode <on/off>")
|
||||
return
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
|
||||
moderator_id = await _get_moderator_id(twitch)
|
||||
|
||||
is_active = args[0].lower() == "on"
|
||||
await twitch.update_shield_mode_status(broadcaster_id, moderator_id, is_active)
|
||||
_log_action("shieldmode", msg.user.name, None, "on" if is_active else "off")
|
||||
logger.info(f'Shield mode {"activé" if is_active else "désactivé"} par {msg.user.name}')
|
||||
|
||||
|
||||
async def settitle_command(msg: ChatMessage, twitch: Twitch):
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
parts = msg.text.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
await msg.reply("Usage: !settitle <titre>")
|
||||
return
|
||||
|
||||
title = parts[1]
|
||||
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
|
||||
|
||||
await twitch.modify_channel_information(broadcaster_id, title=title)
|
||||
_log_action("settitle", msg.user.name, None, title)
|
||||
logger.info(f'Titre changé en "{title}" par {msg.user.name}')
|
||||
|
||||
|
||||
async def setgame_command(msg: ChatMessage, twitch: Twitch):
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
parts = msg.text.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
await msg.reply("Usage: !setgame <jeu>")
|
||||
return
|
||||
|
||||
game_name = parts[1]
|
||||
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
|
||||
|
||||
game_id = None
|
||||
async for game in twitch.get_games(names=[game_name]):
|
||||
game_id = game.id
|
||||
break
|
||||
|
||||
if game_id:
|
||||
await twitch.modify_channel_information(broadcaster_id, game_id=game_id)
|
||||
_log_action("setgame", msg.user.name, None, game_name)
|
||||
logger.info(f'Jeu changé en "{game_name}" par {msg.user.name}')
|
||||
else:
|
||||
await msg.reply(f"Jeu '{game_name}' introuvable")
|
||||
|
||||
|
||||
async def subon_command(msg: ChatMessage, twitch: Twitch):
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
|
||||
moderator_id = await _get_moderator_id(twitch)
|
||||
|
||||
await twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=True)
|
||||
_log_action("subon", msg.user.name)
|
||||
logger.info(f'Mode abonnés activé par {msg.user.name}')
|
||||
|
||||
|
||||
async def suboff_command(msg: ChatMessage, twitch: Twitch):
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
|
||||
moderator_id = await _get_moderator_id(twitch)
|
||||
|
||||
await twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=False)
|
||||
_log_action("suboff", msg.user.name)
|
||||
logger.info(f'Mode abonnés désactivé par {msg.user.name}')
|
||||
|
||||
|
||||
async def follon_command(msg: ChatMessage, twitch: Twitch):
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
args = msg.text.split()[1:]
|
||||
duration = 10
|
||||
if len(args) >= 1:
|
||||
try:
|
||||
duration = int(args[0])
|
||||
except ValueError:
|
||||
duration = 10
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
|
||||
moderator_id = await _get_moderator_id(twitch)
|
||||
|
||||
await twitch.update_chat_settings(broadcaster_id, moderator_id, follower_mode=True, follower_mode_duration=duration)
|
||||
_log_action("follon", msg.user.name, None, f"{duration}min")
|
||||
logger.info(f'Mode followers ({duration}min) activé par {msg.user.name}')
|
||||
|
||||
|
||||
async def folloff_command(msg: ChatMessage, twitch: Twitch):
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
|
||||
moderator_id = await _get_moderator_id(twitch)
|
||||
|
||||
await twitch.update_chat_settings(broadcaster_id, moderator_id, follower_mode=False)
|
||||
_log_action("folloff", msg.user.name)
|
||||
logger.info(f'Mode followers désactivé par {msg.user.name}')
|
||||
|
||||
|
||||
async def emoteon_command(msg: ChatMessage, twitch: Twitch):
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
|
||||
moderator_id = await _get_moderator_id(twitch)
|
||||
|
||||
await twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=True)
|
||||
_log_action("emoteon", msg.user.name)
|
||||
logger.info(f'Mode emote activé par {msg.user.name}')
|
||||
|
||||
|
||||
async def emoteoff_command(msg: ChatMessage, twitch: Twitch):
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
|
||||
moderator_id = await _get_moderator_id(twitch)
|
||||
|
||||
await twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=False)
|
||||
_log_action("emoteoff", msg.user.name)
|
||||
logger.info(f'Mode emote désactivé par {msg.user.name}')
|
||||
|
||||
|
||||
async def multitwitch_command(msg: ChatMessage, twitch: Twitch):
|
||||
global last_multitwitch
|
||||
|
||||
args = msg.text.split()[1:]
|
||||
|
||||
if len(args) == 0:
|
||||
if last_multitwitch:
|
||||
await msg.reply(last_multitwitch)
|
||||
return
|
||||
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
if args[0].lower() == "reset":
|
||||
last_multitwitch = None
|
||||
logger.info(f'MultiTwitch reset par {msg.user.name}')
|
||||
return
|
||||
|
||||
if args[0].lower() == "auto":
|
||||
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
|
||||
async for channel in twitch.get_channel_information(broadcaster_id):
|
||||
title = channel.title
|
||||
mentions = re.findall(r'@(\w+)', title)
|
||||
if mentions:
|
||||
channels = [msg.room.name] + mentions
|
||||
last_multitwitch = f"https://multitwitch.live/{'/'.join(channels)}"
|
||||
await msg.reply(last_multitwitch)
|
||||
return
|
||||
return
|
||||
|
||||
channels = []
|
||||
for arg in args:
|
||||
if arg == "@":
|
||||
channels.append(msg.room.name)
|
||||
else:
|
||||
channels.append(arg.lstrip('@'))
|
||||
|
||||
last_multitwitch = f"https://multitwitch.live/{'/'.join(channels)}"
|
||||
await msg.reply(last_multitwitch)
|
||||
logger.info(f'MultiTwitch créé par {msg.user.name}: {last_multitwitch}')
|
||||
|
||||
|
||||
async def ann_command(msg: ChatMessage, twitch: Twitch):
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
args = msg.text.split()[1:]
|
||||
if len(args) < 2:
|
||||
await msg.reply("Usage: !ann <alias> <on/off/toggle>")
|
||||
return
|
||||
|
||||
alias = args[0]
|
||||
action = args[1].lower()
|
||||
|
||||
with webapp.app_context():
|
||||
announcement = TwitchAnnouncement.query.filter_by(name=alias).first()
|
||||
if not announcement:
|
||||
await msg.reply(f"Annonce '{alias}' introuvable")
|
||||
return
|
||||
|
||||
if action == "on":
|
||||
announcement.enable = True
|
||||
elif action == "off":
|
||||
announcement.enable = False
|
||||
elif action == "toggle":
|
||||
announcement.enable = not announcement.enable
|
||||
else:
|
||||
await msg.reply("Action invalide: on/off/toggle")
|
||||
return
|
||||
|
||||
db.session.commit()
|
||||
status = "activée" if announcement.enable else "désactivée"
|
||||
logger.info(f'Annonce {alias} {status} par {msg.user.name}')
|
||||
await msg.reply(f"Annonce '{alias}' {status}")
|
||||
|
||||
|
||||
async def no_game_command(msg: ChatMessage, twitch: Twitch):
|
||||
global games_disabled
|
||||
|
||||
if not _is_moderator(msg):
|
||||
return
|
||||
|
||||
args = msg.text.split()[1:]
|
||||
if len(args) < 1:
|
||||
await msg.reply("Usage: !no_game <on/off>")
|
||||
return
|
||||
|
||||
action = args[0].lower()
|
||||
|
||||
if action == "on":
|
||||
games_disabled = True
|
||||
logger.info(f'Jeux désactivés par {msg.user.name}')
|
||||
await msg.reply("Jeux désactivés")
|
||||
elif action == "off":
|
||||
games_disabled = False
|
||||
logger.info(f'Jeux activés par {msg.user.name}')
|
||||
await msg.reply("Jeux activés")
|
||||
|
||||
|
||||
def are_games_disabled() -> bool:
|
||||
return games_disabled
|
||||
|
||||
|
||||
async def check_message_for_banned_words(msg: ChatMessage, twitch: Twitch) -> bool:
|
||||
"""
|
||||
Vérifie si le message contient des mots interdits.
|
||||
Retourne True si le message est valide, False s'il doit être bloqué.
|
||||
"""
|
||||
# Modérateurs et broadcaster exemptés
|
||||
if msg.user.mod or msg.user.name.lower() == msg.room.name.lower():
|
||||
return True
|
||||
|
||||
with webapp.app_context():
|
||||
banned_words = TwitchBannedWord.query.filter_by(enabled=True).all()
|
||||
if not banned_words:
|
||||
return True
|
||||
|
||||
message_lower = msg.text.lower()
|
||||
|
||||
for banned_word_entry in banned_words:
|
||||
word = banned_word_entry.word.lower()
|
||||
# Recherche du mot dans le message (mot entier ou partie de mot)
|
||||
if word in message_lower:
|
||||
# Bloquer le message
|
||||
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
|
||||
moderator_id = await _get_moderator_id(twitch)
|
||||
user_id = await _get_user_id(twitch, msg.user.name)
|
||||
|
||||
# Timeout de l'utilisateur
|
||||
if user_id and banned_word_entry.timeout_duration > 0:
|
||||
try:
|
||||
await twitch.ban_user(
|
||||
broadcaster_id,
|
||||
moderator_id,
|
||||
user_id,
|
||||
reason=f"Mot interdit: {banned_word_entry.word}",
|
||||
duration=banned_word_entry.timeout_duration
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur timeout mot interdit: {e}")
|
||||
|
||||
# Suppression du message
|
||||
try:
|
||||
await twitch.delete_chat_messages(broadcaster_id, moderator_id, message_id=msg.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur suppression message mot interdit: {e}")
|
||||
|
||||
# Log
|
||||
_log_action("banned_word", "AutoMod", msg.user.name, f"Mot: {banned_word_entry.word}")
|
||||
logger.info(f"Mot interdit détecté de {msg.user.name}: {banned_word_entry.word}")
|
||||
|
||||
return False
|
||||
|
||||
return True
|
||||
+53
-3
@@ -1,7 +1,57 @@
|
||||
from flask import Flask
|
||||
import os
|
||||
from flask import Flask
|
||||
from flask_login import LoginManager
|
||||
|
||||
webapp = Flask(__name__)
|
||||
webapp.secret_key = os.environ.get('FLASK_SECRET_KEY', 'mamie-henriette-secret-key-change-me')
|
||||
|
||||
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, freegames, youtube_alert
|
||||
# Secret key pour les sessions (Flask-Login)
|
||||
webapp.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-production")
|
||||
|
||||
# État des bots (mis à jour par les bots, lu par le panneau)
|
||||
webapp.config["BOT_STATUS"] = {
|
||||
"discord_connected": False,
|
||||
"discord_guild_count": 0,
|
||||
"twitch_connected": False,
|
||||
"twitch_channel_name": None,
|
||||
"twitch_is_live": False,
|
||||
"twitch_viewer_count": 0,
|
||||
"twitch_chat_messages": [], # Derniers messages du chat (max 100)
|
||||
}
|
||||
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(webapp)
|
||||
login_manager.login_view = "login"
|
||||
login_manager.login_message = "Veuillez vous connecter pour accéder à cette page."
|
||||
|
||||
from database.models import WebappUser
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
try:
|
||||
return WebappUser.query.get(int(user_id))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
from webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot
|
||||
|
||||
from flask import request, redirect, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
@webapp.context_processor
|
||||
def inject_user_level():
|
||||
from flask_login import current_user
|
||||
from database.helpers import ConfigurationHelper
|
||||
reg = ConfigurationHelper().getValue("registration_enabled")
|
||||
registration_enabled = reg not in (None, "", "false", "0", "no", "off")
|
||||
return {
|
||||
"current_user_level": current_user.get_level() if current_user.is_authenticated else -1,
|
||||
"registration_enabled": registration_enabled,
|
||||
}
|
||||
|
||||
@webapp.before_request
|
||||
def require_login():
|
||||
"""Redirige vers /login si non authentifié (sauf login, register, static, callback Twitch OAuth)."""
|
||||
if request.endpoint in (None, "login", "register", "static", "twitchReceiveToken"):
|
||||
return
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for("login", next=request.url))
|
||||
|
||||
@@ -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
@@ -0,0 +1,164 @@
|
||||
# Authentification webapp : login, register, logout et contrôle d'accès par rôles.
|
||||
from functools import wraps
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
from database import db
|
||||
from database.models import WebappUser, ROLE_ORDER, PagePermission
|
||||
from database.helpers import ConfigurationHelper
|
||||
|
||||
from webapp import webapp
|
||||
|
||||
|
||||
def require_roles(allowed_roles: list):
|
||||
"""Décorateur : exige que l'utilisateur soit authentifié et ait l'un des rôles autorisés."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for("login", next=request.url))
|
||||
if current_user.role not in allowed_roles:
|
||||
return render_template("403.html"), 403
|
||||
return f(*args, **kwargs)
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
|
||||
def require_role_min(min_role: str):
|
||||
"""Décorateur : exige que l'utilisateur ait au moins le rôle min_role (niveau en base)."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for("login", next=request.url))
|
||||
if not current_user.has_role_at_least(min_role):
|
||||
return render_template("403.html"), 403
|
||||
return f(*args, **kwargs)
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
|
||||
def _page_min_level(page_key: str, for_write: bool = False) -> int:
|
||||
"""Niveau minimum requis pour la page (lecture ou écriture)."""
|
||||
perm = PagePermission.query.filter_by(page_key=page_key).first()
|
||||
if not perm:
|
||||
return 0
|
||||
if for_write and perm.write_level is not None:
|
||||
return perm.write_level
|
||||
return perm.min_level
|
||||
|
||||
|
||||
def require_page(page_key: str):
|
||||
"""Décorateur : accès en lecture selon les permissions de la page (webapp_page_permission)."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for("login", next=request.url))
|
||||
min_level = _page_min_level(page_key, for_write=False)
|
||||
if not current_user.has_level_at_least(min_level):
|
||||
return render_template("403.html"), 403
|
||||
return f(*args, **kwargs)
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
|
||||
def require_page_write(page_key: str):
|
||||
"""Décorateur : accès en écriture selon les permissions de la page."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for("login", next=request.url))
|
||||
min_level = _page_min_level(page_key, for_write=True)
|
||||
if not current_user.has_level_at_least(min_level):
|
||||
return render_template("403.html"), 403
|
||||
return f(*args, **kwargs)
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
|
||||
def can_write_page(page_key: str) -> bool:
|
||||
"""Retourne True si l'utilisateur connecté a le niveau pour écrire sur cette page."""
|
||||
if not current_user.is_authenticated:
|
||||
return False
|
||||
return current_user.has_level_at_least(_page_min_level(page_key, for_write=True))
|
||||
|
||||
|
||||
@webapp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("index"))
|
||||
if request.method == "POST":
|
||||
identifier = (request.form.get("identifier") or "").strip()
|
||||
password = request.form.get("password") or ""
|
||||
if not identifier or not password:
|
||||
flash("Identifiant et mot de passe requis.", "error")
|
||||
return render_template("login.html")
|
||||
user = WebappUser.query.filter(
|
||||
(WebappUser.username == identifier) | (WebappUser.email == identifier)
|
||||
).first()
|
||||
if user and check_password_hash(user.password_hash, password):
|
||||
login_user(user, remember=True)
|
||||
next_url = request.args.get("next")
|
||||
if next_url and next_url.startswith("/"):
|
||||
return redirect(next_url)
|
||||
return redirect(url_for("index"))
|
||||
flash("Identifiant ou mot de passe incorrect.", "error")
|
||||
return render_template("login.html")
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
@webapp.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("index"))
|
||||
# Inscriptions désactivées par le super admin
|
||||
reg_enabled = ConfigurationHelper().getValue("registration_enabled")
|
||||
if reg_enabled in (None, "", "false", "0", "no", "off"):
|
||||
flash("Les inscriptions sont désactivées.", "error")
|
||||
return redirect(url_for("login"))
|
||||
if request.method == "POST":
|
||||
username = (request.form.get("username") or "").strip()
|
||||
email = (request.form.get("email") or "").strip().lower()
|
||||
password = request.form.get("password") or ""
|
||||
password_confirm = request.form.get("password_confirm") or ""
|
||||
errors = []
|
||||
if len(username) < 3:
|
||||
errors.append("Le nom d'utilisateur doit faire au moins 3 caractères.")
|
||||
if len(email) < 5 or "@" not in email:
|
||||
errors.append("Adresse e-mail invalide.")
|
||||
if len(password) < 8:
|
||||
errors.append("Le mot de passe doit faire au moins 8 caractères.")
|
||||
if password != password_confirm:
|
||||
errors.append("Les mots de passe ne correspondent pas.")
|
||||
if WebappUser.query.filter_by(username=username).first():
|
||||
errors.append("Ce nom d'utilisateur est déjà pris.")
|
||||
if WebappUser.query.filter_by(email=email).first():
|
||||
errors.append("Cette adresse e-mail est déjà utilisée.")
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, "error")
|
||||
return render_template("register.html")
|
||||
# Premier inscrit = super administrateur
|
||||
role = "super_administrateur" if WebappUser.query.count() == 0 else "viewer_twitch"
|
||||
user = WebappUser(
|
||||
username=username,
|
||||
email=email,
|
||||
password_hash=generate_password_hash(password, method="scrypt"),
|
||||
role=role,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
flash("Compte créé. Vous pouvez vous connecter.", "success")
|
||||
return redirect(url_for("login"))
|
||||
return render_template("register.html")
|
||||
|
||||
|
||||
@webapp.route("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for("login"))
|
||||
+22
-2
@@ -1,19 +1,30 @@
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import Commande
|
||||
|
||||
@webapp.route("/commandes")
|
||||
@require_page("commandes")
|
||||
def commandes():
|
||||
commandes_list = Commande.query.all()
|
||||
return render_template("commandes.html", commandes=commandes_list)
|
||||
return render_template("commandes.html", commandes=commandes_list, twitch_permissions=TWITCH_PERMISSIONS)
|
||||
|
||||
TWITCH_PERMISSIONS = {'viewer': 'Tous (viewers)', 'sub': 'Abonnés', 'vip': 'VIP', 'moderator': 'Modérateur'}
|
||||
|
||||
|
||||
@webapp.route("/commandes/add", methods=['POST'])
|
||||
@require_page("commandes")
|
||||
def add_commande():
|
||||
if not can_write_page("commandes"):
|
||||
return render_template("403.html"), 403
|
||||
trigger = request.form.get('trigger')
|
||||
response = request.form.get('response')
|
||||
discord_enable = request.form.get('discord_enable') != None
|
||||
twitch_enable = request.form.get('twitch_enable') != None
|
||||
twitch_permission = request.form.get('twitch_permission') or 'viewer'
|
||||
if twitch_permission not in TWITCH_PERMISSIONS:
|
||||
twitch_permission = 'viewer'
|
||||
|
||||
if trigger and response:
|
||||
if not trigger.startswith('!'):
|
||||
@@ -21,28 +32,37 @@ def add_commande():
|
||||
|
||||
existing = Commande.query.filter_by(trigger=trigger).first()
|
||||
if not existing:
|
||||
commande = Commande(trigger=trigger, response=response, discord_enable=discord_enable, twitch_enable=twitch_enable)
|
||||
commande = Commande(trigger=trigger, response=response, discord_enable=discord_enable, twitch_enable=twitch_enable, twitch_permission=twitch_permission)
|
||||
db.session.add(commande)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('commandes'))
|
||||
|
||||
@webapp.route("/commandes/delete/<int:commande_id>")
|
||||
@require_page("commandes")
|
||||
def delete_commande(commande_id):
|
||||
if not can_write_page("commandes"):
|
||||
return render_template("403.html"), 403
|
||||
commande = Commande.query.get_or_404(commande_id)
|
||||
db.session.delete(commande)
|
||||
db.session.commit()
|
||||
return redirect(url_for('commandes'))
|
||||
|
||||
@webapp.route("/commandes/toggle-discord/<int:commande_id>")
|
||||
@require_page("commandes")
|
||||
def toggle_discord_commande(commande_id):
|
||||
if not can_write_page("commandes"):
|
||||
return render_template("403.html"), 403
|
||||
commande = Commande.query.get_or_404(commande_id)
|
||||
commande.discord_enable = not commande.discord_enable
|
||||
db.session.commit()
|
||||
return redirect(url_for('commandes'))
|
||||
|
||||
@webapp.route("/commandes/toggle-twitch/<int:commande_id>")
|
||||
@require_page("commandes")
|
||||
def toggle_twitch_commande(commande_id):
|
||||
if not can_write_page("commandes"):
|
||||
return render_template("403.html"), 403
|
||||
commande = Commande.query.get_or_404(commande_id)
|
||||
commande.twitch_enable = not commande.twitch_enable
|
||||
db.session.commit()
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from discordbot import bot
|
||||
import asyncio
|
||||
|
||||
@webapp.route("/configurations")
|
||||
@require_page("configurations")
|
||||
def openConfigurations():
|
||||
return render_template("configurations.html", configuration = ConfigurationHelper(), channels = bot.getAllTextChannel(), roles = bot.getAllRoles(), guilds = bot.getAllGuilds())
|
||||
return render_template("configurations.html", configuration=ConfigurationHelper(), channels=bot.getAllTextChannel(), voice_channels=bot.getAllVoiceChannels(), roles=bot.getAllRoles())
|
||||
|
||||
@webapp.route("/configurations/update", methods=['POST'])
|
||||
@webapp.route("/configurations/update", methods=['POST'])
|
||||
@require_page("configurations")
|
||||
def updateConfiguration():
|
||||
checkboxes = {
|
||||
'humble_bundle_enable': 'humble_bundle_channel',
|
||||
@@ -19,7 +21,8 @@ def updateConfiguration():
|
||||
'moderation_kick_enable': 'moderation_staff_role_ids',
|
||||
'welcome_enable': 'welcome_channel_id',
|
||||
'leave_enable': 'leave_channel_id',
|
||||
'autorole_enable': 'autorole_role_id'
|
||||
'auto_rooms_enable': 'auto_rooms_channel_id',
|
||||
'twitch_commands_enable': 'twitch_channel'
|
||||
}
|
||||
|
||||
staff_roles = request.form.getlist('moderation_staff_role_ids')
|
||||
@@ -42,27 +45,3 @@ def updateConfiguration():
|
||||
db.session.commit()
|
||||
return redirect(request.referrer)
|
||||
|
||||
@webapp.route("/configurations/leave-guild", methods=['POST'])
|
||||
def leaveGuild():
|
||||
guild_id = request.form.get('guild_id')
|
||||
confirm = request.form.get('confirm_leave')
|
||||
|
||||
if not guild_id or not confirm:
|
||||
flash('Veuillez sélectionner un serveur et confirmer l\'action.', 'error')
|
||||
return redirect(url_for('openConfigurations'))
|
||||
|
||||
try:
|
||||
guild_id_int = int(guild_id)
|
||||
# Exécuter la coroutine dans le loop du bot
|
||||
future = asyncio.run_coroutine_threadsafe(bot.leaveGuild(guild_id_int), bot.loop)
|
||||
result = future.result(timeout=10)
|
||||
|
||||
if result:
|
||||
flash('Le bot a quitté le serveur avec succès.', 'success')
|
||||
else:
|
||||
flash('Serveur non trouvé.', 'error')
|
||||
except Exception as e:
|
||||
flash(f'Erreur lors de la tentative de quitter le serveur : {str(e)}', 'error')
|
||||
|
||||
return redirect(url_for('openConfigurations'))
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
from flask import render_template, request, redirect, url_for, jsonify
|
||||
from webapp import webapp
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import FreeGame
|
||||
from discordbot import bot
|
||||
from discordbot.freegames import (
|
||||
fetchAndStoreGames,
|
||||
getPendingGames,
|
||||
getAllGames,
|
||||
markAsNotified,
|
||||
resetNotification,
|
||||
KNOWN_SOURCES
|
||||
)
|
||||
import asyncio
|
||||
|
||||
|
||||
@webapp.route("/freegames")
|
||||
def openFreeGames():
|
||||
"""Page principale de gestion des jeux gratuits"""
|
||||
# Rafraîchir les jeux depuis le flux RSS
|
||||
fetchAndStoreGames()
|
||||
|
||||
games = getAllGames()
|
||||
pending_games = getPendingGames()
|
||||
|
||||
return render_template(
|
||||
"freegames.html",
|
||||
configuration=ConfigurationHelper(),
|
||||
channels=bot.getAllTextChannel(),
|
||||
roles=bot.getAllRoles(),
|
||||
games=games,
|
||||
pending_count=len(pending_games),
|
||||
sources=KNOWN_SOURCES
|
||||
)
|
||||
|
||||
|
||||
@webapp.route("/freegames/config", methods=['POST'])
|
||||
def updateFreeGamesConfig():
|
||||
"""Met à jour la configuration du module Free Games"""
|
||||
helper = ConfigurationHelper()
|
||||
|
||||
# Gestion de la checkbox enable
|
||||
if request.form.get('freegames_channel') is not None:
|
||||
if request.form.get('freegames_enable') is None:
|
||||
helper.createOrUpdate('freegames_enable', False)
|
||||
else:
|
||||
helper.createOrUpdate('freegames_enable', 'on')
|
||||
|
||||
# Gestion de la checkbox auto_notify
|
||||
if request.form.get('freegames_auto_notify') is None:
|
||||
helper.createOrUpdate('freegames_auto_notify', False)
|
||||
else:
|
||||
helper.createOrUpdate('freegames_auto_notify', 'on')
|
||||
|
||||
# Canal de notification
|
||||
if request.form.get('freegames_channel'):
|
||||
helper.createOrUpdate('freegames_channel', request.form.get('freegames_channel'))
|
||||
|
||||
# Type de mention
|
||||
if request.form.get('freegames_mention_type'):
|
||||
helper.createOrUpdate('freegames_mention_type', request.form.get('freegames_mention_type'))
|
||||
|
||||
# Rôle à mentionner
|
||||
if request.form.get('freegames_mention_role'):
|
||||
helper.createOrUpdate('freegames_mention_role', request.form.get('freegames_mention_role'))
|
||||
|
||||
# Sources à suivre
|
||||
sources = request.form.getlist('freegames_sources')
|
||||
helper.createOrUpdate('freegames_sources', ','.join(sources) if sources else '')
|
||||
|
||||
db.session.commit()
|
||||
return redirect(url_for('openFreeGames'))
|
||||
|
||||
|
||||
@webapp.route("/freegames/notify/<int:game_id>", methods=['POST'])
|
||||
def notifyFreeGame(game_id):
|
||||
"""Envoie une notification pour un jeu spécifique"""
|
||||
from discordbot.freegames import notifyGame
|
||||
|
||||
try:
|
||||
# Utiliser le loop du bot Discord pour exécuter la coroutine
|
||||
if bot.loop and bot.loop.is_running():
|
||||
future = asyncio.run_coroutine_threadsafe(notifyGame(bot, game_id), bot.loop)
|
||||
result = future.result(timeout=30) # Attendre max 30 secondes
|
||||
else:
|
||||
return jsonify({'success': False, 'message': 'Le bot Discord n\'est pas connecté'}), 400
|
||||
|
||||
if result:
|
||||
return jsonify({'success': True, 'message': 'Notification envoyée'})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': 'Échec de l\'envoi'}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@webapp.route("/freegames/mark-notified/<int:game_id>", methods=['POST'])
|
||||
def markGameNotified(game_id):
|
||||
"""Marque un jeu comme notifié sans envoyer de message"""
|
||||
if markAsNotified(game_id):
|
||||
return jsonify({'success': True})
|
||||
return jsonify({'success': False}), 400
|
||||
|
||||
|
||||
@webapp.route("/freegames/reset/<int:game_id>", methods=['POST'])
|
||||
def resetGameNotification(game_id):
|
||||
"""Réinitialise le statut de notification d'un jeu"""
|
||||
if resetNotification(game_id):
|
||||
return jsonify({'success': True})
|
||||
return jsonify({'success': False}), 400
|
||||
|
||||
|
||||
@webapp.route("/freegames/refresh", methods=['POST'])
|
||||
def refreshFreeGames():
|
||||
"""Force le rafraîchissement du flux RSS"""
|
||||
new_games = fetchAndStoreGames()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'new_games': len(new_games),
|
||||
'message': f'{len(new_games)} nouveau(x) jeu(x) trouvé(s)'
|
||||
})
|
||||
|
||||
|
||||
@webapp.route("/freegames/delete/<int:game_id>", methods=['POST'])
|
||||
def deleteFreeGame(game_id):
|
||||
"""Supprime un jeu de la liste"""
|
||||
game = FreeGame.query.get(game_id)
|
||||
if game:
|
||||
db.session.delete(game)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
return jsonify({'success': False}), 404
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Page webapp : configuration des notifications FreeLoot (feed LootScraper)
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from discordbot import bot
|
||||
from discordbot.freeloot import send_entry_to_discord_sync
|
||||
from freeloot_feed import SOURCES, get_display_entries
|
||||
|
||||
|
||||
def _format_updated(updated: str | None) -> str:
|
||||
"""Formate la date ISO en affichage court."""
|
||||
if not updated:
|
||||
return ""
|
||||
try:
|
||||
from datetime import datetime
|
||||
dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
|
||||
return dt.strftime("%d/%m/%Y %H:%M")
|
||||
except Exception:
|
||||
return updated[:16] if len(updated or "") >= 16 else (updated or "")
|
||||
|
||||
|
||||
def _parse_mention_config(raw: str | None) -> tuple[bool, bool, list[str]]:
|
||||
"""Retourne (everyone, here, list of role_ids) depuis freeloot_mention."""
|
||||
everyone, here, role_ids = False, False, []
|
||||
if not raw or not str(raw).strip():
|
||||
return (everyone, here, role_ids)
|
||||
for part in str(raw).strip().split(","):
|
||||
part = part.strip()
|
||||
if part == "everyone":
|
||||
everyone = True
|
||||
elif part == "here":
|
||||
here = True
|
||||
elif part.isdigit():
|
||||
role_ids.append(part)
|
||||
return (everyone, here, role_ids)
|
||||
|
||||
|
||||
@webapp.route("/freeloot")
|
||||
@require_page("freeloot")
|
||||
def openFreeLoot():
|
||||
helper = ConfigurationHelper()
|
||||
channels = bot.getAllTextChannel()
|
||||
roles = bot.getAllRoles()
|
||||
raw_sources = helper.getValue("freeloot_sources")
|
||||
enabled_sources = []
|
||||
if raw_sources and str(raw_sources).strip():
|
||||
enabled_sources = [s.strip() for s in str(raw_sources).split(",") if s.strip()]
|
||||
raw_mention = helper.getValue("freeloot_mention")
|
||||
mention_everyone, mention_here, mention_role_ids = _parse_mention_config(raw_mention)
|
||||
entries = get_display_entries()
|
||||
if enabled_sources:
|
||||
entries = [e for e in entries if e.get("source_key") in enabled_sources]
|
||||
for e in entries:
|
||||
e["updated_formatted"] = _format_updated(e.get("updated"))
|
||||
return render_template(
|
||||
"freeloot.html",
|
||||
configuration=helper,
|
||||
channels=channels,
|
||||
roles=roles,
|
||||
sources=SOURCES,
|
||||
enabled_sources=enabled_sources,
|
||||
mention_everyone=mention_everyone,
|
||||
mention_here=mention_here,
|
||||
mention_role_ids=mention_role_ids,
|
||||
entries=entries,
|
||||
)
|
||||
|
||||
|
||||
@webapp.route("/freeloot/update", methods=["POST"])
|
||||
@require_page("freeloot")
|
||||
def updateFreeLoot():
|
||||
if not can_write_page("freeloot"):
|
||||
return render_template("403.html"), 403
|
||||
helper = ConfigurationHelper()
|
||||
enable = request.form.get("freeloot_enable") in ("on", "1", "true", "yes")
|
||||
channel_id = request.form.get("freeloot_channel_id")
|
||||
source_keys = request.form.getlist("freeloot_sources")
|
||||
mention_parts = []
|
||||
if request.form.get("freeloot_mention_everyone"):
|
||||
mention_parts.append("everyone")
|
||||
if request.form.get("freeloot_mention_here"):
|
||||
mention_parts.append("here")
|
||||
mention_parts.extend(request.form.getlist("freeloot_mention_roles"))
|
||||
helper.createOrUpdate("freeloot_enable", "true" if enable else "false")
|
||||
if channel_id:
|
||||
try:
|
||||
helper.createOrUpdate("freeloot_channel_id", str(int(channel_id)))
|
||||
except ValueError:
|
||||
pass
|
||||
helper.createOrUpdate("freeloot_sources", ",".join(source_keys) if source_keys else "")
|
||||
helper.createOrUpdate("freeloot_mention", ",".join(mention_parts))
|
||||
db.session.commit()
|
||||
return redirect(url_for("openFreeLoot") + "?msg=Configuration enregistrée.&type=success")
|
||||
|
||||
|
||||
@webapp.route("/freeloot/send", methods=["POST"])
|
||||
@require_page("freeloot")
|
||||
def send_free_loot_to_discord():
|
||||
if not can_write_page("freeloot"):
|
||||
return render_template("403.html"), 403
|
||||
entry_id = (request.form.get("entry_id") or "").strip()
|
||||
if not entry_id:
|
||||
return redirect(url_for("openFreeLoot") + "?" + urlencode({"msg": "Entrée manquante.", "type": "error"}))
|
||||
ok, message = send_entry_to_discord_sync(bot, entry_id)
|
||||
msg_type = "success" if ok else "error"
|
||||
return redirect(url_for("openFreeLoot") + "?" + urlencode({"msg": message, "type": msg_type}))
|
||||
+9
-1
@@ -1,22 +1,30 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import Humeur
|
||||
|
||||
@webapp.route("/humeurs")
|
||||
@require_page("humeurs")
|
||||
def listHumeurs():
|
||||
humeurs = Humeur.query.all()
|
||||
return render_template("humeurs.html", humeurs = humeurs)
|
||||
return render_template("humeurs.html", humeurs=humeurs)
|
||||
|
||||
@webapp.route('/humeurs/add', methods=['POST'])
|
||||
@require_page("humeurs")
|
||||
def addHumeur():
|
||||
if not can_write_page("humeurs"):
|
||||
return render_template("403.html"), 403
|
||||
humeur = Humeur(text=request.form['text'])
|
||||
db.session.add(humeur)
|
||||
db.session.commit()
|
||||
return redirect(url_for('listHumeurs'))
|
||||
|
||||
@webapp.route('/humeurs/del/<id>')
|
||||
@require_page("humeurs")
|
||||
def delHumeur(id):
|
||||
if not can_write_page("humeurs"):
|
||||
return render_template("403.html"), 403
|
||||
Humeur.query.filter_by(id=id).delete()
|
||||
db.session.commit()
|
||||
return redirect(url_for('listHumeurs'))
|
||||
|
||||
+18
-40
@@ -1,44 +1,22 @@
|
||||
from flask import render_template, request, jsonify
|
||||
from flask import render_template
|
||||
from webapp import webapp
|
||||
from shared_stats import stats_manager, discord_bridge
|
||||
from webapp.auth import require_page
|
||||
from database.models import ModerationEvent, TwitchAnnouncement, TwitchModerationLog
|
||||
|
||||
@webapp.route("/")
|
||||
@require_page("index")
|
||||
def index():
|
||||
stats = stats_manager.get_stats()
|
||||
channels = discord_bridge.get_text_channels()
|
||||
return render_template("index.html", stats=stats, channels=channels)
|
||||
|
||||
@webapp.route("/api/channels")
|
||||
def api_channels():
|
||||
"""API pour récupérer la liste des canaux Discord"""
|
||||
channels = discord_bridge.get_text_channels()
|
||||
return jsonify(channels)
|
||||
|
||||
@webapp.route("/api/send-message", methods=["POST"])
|
||||
def api_send_message():
|
||||
"""API pour envoyer un message dans un canal Discord"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({"success": False, "error": "Données manquantes"}), 400
|
||||
|
||||
channel_id = data.get("channel_id")
|
||||
message = data.get("message")
|
||||
|
||||
if not channel_id:
|
||||
return jsonify({"success": False, "error": "Canal non spécifié"}), 400
|
||||
|
||||
if not message or not message.strip():
|
||||
return jsonify({"success": False, "error": "Message vide"}), 400
|
||||
|
||||
try:
|
||||
channel_id = int(channel_id)
|
||||
except ValueError:
|
||||
return jsonify({"success": False, "error": "ID de canal invalide"}), 400
|
||||
|
||||
success, result_message = discord_bridge.send_message(channel_id, message)
|
||||
|
||||
if success:
|
||||
return jsonify({"success": True, "message": result_message})
|
||||
else:
|
||||
return jsonify({"success": False, "error": result_message}), 400
|
||||
status = webapp.config["BOT_STATUS"]
|
||||
sanctions_count = ModerationEvent.query.count()
|
||||
twitch_announcements_count = TwitchAnnouncement.query.count()
|
||||
twitch_moderation_count = TwitchModerationLog.query.count()
|
||||
return render_template(
|
||||
"index.html",
|
||||
discord_connected=status["discord_connected"],
|
||||
discord_guild_count=status["discord_guild_count"],
|
||||
sanctions_count=sanctions_count,
|
||||
twitch_connected=status["twitch_connected"],
|
||||
twitch_channel_name=status["twitch_channel_name"],
|
||||
twitch_announcements_count=twitch_announcements_count,
|
||||
twitch_moderation_count=twitch_moderation_count,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import TwitchLinkFilter, TwitchAllowedDomain, TwitchAllowedUser
|
||||
|
||||
|
||||
def _get_or_create_config():
|
||||
config = TwitchLinkFilter.query.first()
|
||||
if not config:
|
||||
config = TwitchLinkFilter(enabled=False)
|
||||
db.session.add(config)
|
||||
db.session.commit()
|
||||
return config
|
||||
|
||||
|
||||
@webapp.route("/link-filter")
|
||||
@require_page("link_filter")
|
||||
def link_filter():
|
||||
config = _get_or_create_config()
|
||||
domains = TwitchAllowedDomain.query.order_by(TwitchAllowedDomain.domain).all()
|
||||
users = TwitchAllowedUser.query.order_by(TwitchAllowedUser.username).all()
|
||||
return render_template("link-filter.html", config=config, domains=domains, users=users)
|
||||
|
||||
|
||||
@webapp.route("/link-filter/toggle")
|
||||
@require_page("link_filter")
|
||||
def toggle_link_filter():
|
||||
if not can_write_page("link_filter"):
|
||||
return render_template("403.html"), 403
|
||||
config = _get_or_create_config()
|
||||
config.enabled = not config.enabled
|
||||
db.session.commit()
|
||||
return redirect(url_for('link_filter'))
|
||||
|
||||
|
||||
@webapp.route("/link-filter/update", methods=['POST'])
|
||||
@require_page("link_filter")
|
||||
def update_link_filter():
|
||||
if not can_write_page("link_filter"):
|
||||
return render_template("403.html"), 403
|
||||
config = _get_or_create_config()
|
||||
config.allow_subscribers = request.form.get('allow_subscribers') is not None
|
||||
config.allow_vips = request.form.get('allow_vips') is not None
|
||||
config.allow_moderators = request.form.get('allow_moderators') is not None
|
||||
config.timeout_duration = int(request.form.get('timeout_duration', 60))
|
||||
config.warning_message = request.form.get('warning_message', '')
|
||||
db.session.commit()
|
||||
return redirect(url_for('link_filter'))
|
||||
|
||||
|
||||
@webapp.route("/link-filter/domain/add", methods=['POST'])
|
||||
@require_page("link_filter")
|
||||
def add_allowed_domain():
|
||||
if not can_write_page("link_filter"):
|
||||
return render_template("403.html"), 403
|
||||
domain = request.form.get('domain', '').strip().lower()
|
||||
if domain:
|
||||
domain = domain.replace('https://', '').replace('http://', '').replace('www.', '')
|
||||
domain = domain.split('/')[0]
|
||||
existing = TwitchAllowedDomain.query.filter_by(domain=domain).first()
|
||||
if not existing:
|
||||
new_domain = TwitchAllowedDomain(domain=domain)
|
||||
db.session.add(new_domain)
|
||||
db.session.commit()
|
||||
return redirect(url_for('link_filter'))
|
||||
|
||||
|
||||
@webapp.route("/link-filter/domain/delete/<int:domain_id>")
|
||||
@require_page("link_filter")
|
||||
def delete_allowed_domain(domain_id):
|
||||
if not can_write_page("link_filter"):
|
||||
return render_template("403.html"), 403
|
||||
domain = TwitchAllowedDomain.query.get_or_404(domain_id)
|
||||
db.session.delete(domain)
|
||||
db.session.commit()
|
||||
return redirect(url_for('link_filter'))
|
||||
|
||||
|
||||
@webapp.route("/link-filter/user/add", methods=['POST'])
|
||||
@require_page("link_filter")
|
||||
def add_allowed_user():
|
||||
if not can_write_page("link_filter"):
|
||||
return render_template("403.html"), 403
|
||||
username = request.form.get('username', '').strip().lower().lstrip('@')
|
||||
if username:
|
||||
existing = TwitchAllowedUser.query.filter_by(username=username).first()
|
||||
if not existing:
|
||||
new_user = TwitchAllowedUser(username=username)
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
return redirect(url_for('link_filter'))
|
||||
|
||||
|
||||
@webapp.route("/link-filter/user/delete/<int:user_id>")
|
||||
@require_page("link_filter")
|
||||
def delete_allowed_user(user_id):
|
||||
if not can_write_page("link_filter"):
|
||||
return render_template("403.html"), 403
|
||||
user = TwitchAllowedUser.query.get_or_404(user_id)
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
return redirect(url_for('link_filter'))
|
||||
+57
-3
@@ -1,12 +1,14 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import LiveAlert
|
||||
from discordbot import bot
|
||||
|
||||
|
||||
@webapp.route("/live-alert")
|
||||
@require_page("live_alert")
|
||||
def openLiveAlert():
|
||||
alerts : list[LiveAlert] = LiveAlert.query.all()
|
||||
channels = bot.getAllTextChannel()
|
||||
@@ -17,37 +19,89 @@ def openLiveAlert():
|
||||
return render_template("live-alert.html", alerts = alerts, channels = channels)
|
||||
|
||||
@webapp.route("/live-alert/add", methods=['POST'])
|
||||
@require_page("live_alert")
|
||||
def addLiveAlert():
|
||||
alert = LiveAlert(enable = True, login = request.form.get('login'), notify_channel = request.form.get('notify_channel'), message = request.form.get('message'))
|
||||
if not can_write_page("live_alert"):
|
||||
return render_template("403.html"), 403
|
||||
embed_color = (request.form.get('embed_color') or '9146FF').strip().lstrip('#')
|
||||
if len(embed_color) != 6:
|
||||
embed_color = '9146FF'
|
||||
alert = LiveAlert(
|
||||
enable=True,
|
||||
login=request.form.get('login'),
|
||||
notify_channel=request.form.get('notify_channel'),
|
||||
message=(request.form.get('message') or '').strip(),
|
||||
watch_activity=request.form.get('watch_activity') == '1',
|
||||
embed_title=request.form.get('embed_title') or None,
|
||||
embed_description=request.form.get('embed_description') or None,
|
||||
embed_color=embed_color,
|
||||
embed_footer=request.form.get('embed_footer') or None,
|
||||
embed_author_name=request.form.get('embed_author_name') or None,
|
||||
embed_author_icon=request.form.get('embed_author_icon') or None,
|
||||
embed_thumbnail=request.form.get('embed_thumbnail') == 'on',
|
||||
embed_image=request.form.get('embed_image') == 'on',
|
||||
)
|
||||
db.session.add(alert)
|
||||
db.session.commit()
|
||||
return redirect(url_for("openLiveAlert"))
|
||||
|
||||
@webapp.route("/live-alert/toggle/<int:id>")
|
||||
@require_page("live_alert")
|
||||
def toggleLiveAlert(id):
|
||||
if not can_write_page("live_alert"):
|
||||
return render_template("403.html"), 403
|
||||
alert : LiveAlert = LiveAlert.query.get_or_404(id)
|
||||
alert.enable = not alert.enable
|
||||
db.session.commit()
|
||||
return redirect(url_for("openLiveAlert"))
|
||||
|
||||
@webapp.route("/live-alert/edit/<int:id>")
|
||||
@require_page("live_alert")
|
||||
def openEditLiveAlert(id):
|
||||
alert = LiveAlert.query.get_or_404(id)
|
||||
channels = bot.getAllTextChannel()
|
||||
return render_template("live-alert.html", alert = alert, channels = channels)
|
||||
|
||||
@webapp.route("/live-alert/edit/<int:id>", methods=['POST'])
|
||||
@require_page("live_alert")
|
||||
def submitEditLiveAlert(id):
|
||||
alert : LiveAlert = LiveAlert.query.get_or_404(id)
|
||||
if not can_write_page("live_alert"):
|
||||
return render_template("403.html"), 403
|
||||
alert: LiveAlert = LiveAlert.query.get_or_404(id)
|
||||
embed_color = (request.form.get('embed_color') or '9146FF').strip().lstrip('#')
|
||||
if len(embed_color) != 6:
|
||||
embed_color = '9146FF'
|
||||
alert.login = request.form.get('login')
|
||||
alert.notify_channel = request.form.get('notify_channel')
|
||||
alert.message = request.form.get('message')
|
||||
alert.message = (request.form.get('message') or '').strip()
|
||||
alert.watch_activity = request.form.get('watch_activity') == '1'
|
||||
alert.embed_title = request.form.get('embed_title') or None
|
||||
alert.embed_description = request.form.get('embed_description') or None
|
||||
alert.embed_color = embed_color
|
||||
alert.embed_footer = request.form.get('embed_footer') or None
|
||||
alert.embed_author_name = request.form.get('embed_author_name') or None
|
||||
alert.embed_author_icon = request.form.get('embed_author_icon') or None
|
||||
alert.embed_thumbnail = request.form.get('embed_thumbnail') == 'on'
|
||||
alert.embed_image = request.form.get('embed_image') == 'on'
|
||||
db.session.commit()
|
||||
return redirect(url_for("openLiveAlert"))
|
||||
|
||||
@webapp.route("/live-alert/toggle-watch/<int:id>")
|
||||
@require_page("live_alert")
|
||||
def toggleWatchActivity(id):
|
||||
if not can_write_page("live_alert"):
|
||||
return render_template("403.html"), 403
|
||||
alert : LiveAlert = LiveAlert.query.get_or_404(id)
|
||||
alert.watch_activity = not alert.watch_activity
|
||||
db.session.commit()
|
||||
return redirect(url_for("openLiveAlert"))
|
||||
|
||||
|
||||
@webapp.route("/live-alert/del/<int:id>")
|
||||
@require_page("live_alert")
|
||||
def delLiveAlert(id):
|
||||
if not can_write_page("live_alert"):
|
||||
return render_template("403.html"), 403
|
||||
alert = LiveAlert.query.get_or_404(id)
|
||||
db.session.delete(alert)
|
||||
db.session.commit()
|
||||
|
||||
+54
-178
@@ -1,205 +1,81 @@
|
||||
from flask import render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import ModerationEvent, DiscordInvite
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from collections import Counter
|
||||
from shared_stats import discord_bridge
|
||||
from database.models import ModerationEvent
|
||||
|
||||
def get_moderation_stats():
|
||||
"""Calcule les statistiques de modération"""
|
||||
events = ModerationEvent.query.all()
|
||||
|
||||
if not events:
|
||||
return {
|
||||
'total': 0,
|
||||
'top_moderators': [],
|
||||
'type_counts': {},
|
||||
'recent_24h': 0,
|
||||
'recent_7d': 0,
|
||||
'recent_30d': 0,
|
||||
'top_sanctioned': []
|
||||
}
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
# Comptages par modérateur
|
||||
mod_counts = Counter(e.staff_name for e in events if e.staff_name)
|
||||
top_moderators = mod_counts.most_common(5)
|
||||
|
||||
# Comptages par type
|
||||
type_counts = Counter(e.type for e in events if e.type)
|
||||
|
||||
# Événements récents
|
||||
recent_24h = sum(1 for e in events if e.created_at and (now - e.created_at) < timedelta(hours=24))
|
||||
recent_7d = sum(1 for e in events if e.created_at and (now - e.created_at) < timedelta(days=7))
|
||||
recent_30d = sum(1 for e in events if e.created_at and (now - e.created_at) < timedelta(days=30))
|
||||
|
||||
# Top des utilisateurs les plus sanctionnés
|
||||
user_counts = Counter(e.username for e in events if e.username)
|
||||
top_sanctioned = user_counts.most_common(5)
|
||||
|
||||
return {
|
||||
'total': len(events),
|
||||
'top_moderators': top_moderators,
|
||||
'type_counts': dict(type_counts),
|
||||
'recent_24h': recent_24h,
|
||||
'recent_7d': recent_7d,
|
||||
'recent_30d': recent_30d,
|
||||
'top_sanctioned': top_sanctioned
|
||||
}
|
||||
def _top_sanctioned():
|
||||
return (
|
||||
db.session.query(
|
||||
ModerationEvent.discord_id,
|
||||
db.func.max(ModerationEvent.username).label("username"),
|
||||
db.func.count(ModerationEvent.id).label("count"),
|
||||
)
|
||||
.group_by(ModerationEvent.discord_id)
|
||||
.order_by(db.func.count(ModerationEvent.id).desc())
|
||||
.limit(3)
|
||||
.all()
|
||||
)
|
||||
|
||||
def _top_moderators():
|
||||
return (
|
||||
db.session.query(
|
||||
ModerationEvent.staff_id,
|
||||
db.func.max(ModerationEvent.staff_name).label("staff_name"),
|
||||
db.func.count(ModerationEvent.id).label("count"),
|
||||
)
|
||||
.group_by(ModerationEvent.staff_id)
|
||||
.order_by(db.func.count(ModerationEvent.id).desc())
|
||||
.limit(3)
|
||||
.all()
|
||||
)
|
||||
|
||||
@webapp.route("/moderation")
|
||||
@require_page("moderation")
|
||||
def moderation():
|
||||
events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
|
||||
stats = get_moderation_stats()
|
||||
return render_template("moderation.html", events=events, event=None, stats=stats,
|
||||
invites=[], invite_stats=None, guilds=[], show_invites=False, show_revoked=False)
|
||||
top_sanctioned = _top_sanctioned()
|
||||
top_moderators = _top_moderators()
|
||||
return render_template(
|
||||
"moderation.html",
|
||||
events=events,
|
||||
event=None,
|
||||
top_sanctioned=top_sanctioned,
|
||||
top_moderators=top_moderators,
|
||||
)
|
||||
|
||||
@webapp.route("/moderation/edit/<int:event_id>")
|
||||
@require_page("moderation")
|
||||
def open_edit_moderation_event(event_id):
|
||||
event = ModerationEvent.query.get_or_404(event_id)
|
||||
events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
|
||||
stats = get_moderation_stats()
|
||||
return render_template("moderation.html", events=events, event=event, stats=stats)
|
||||
top_sanctioned = _top_sanctioned()
|
||||
top_moderators = _top_moderators()
|
||||
return render_template(
|
||||
"moderation.html",
|
||||
events=events,
|
||||
event=event,
|
||||
top_sanctioned=top_sanctioned,
|
||||
top_moderators=top_moderators,
|
||||
)
|
||||
|
||||
@webapp.route("/moderation/update/<int:event_id>", methods=['POST'])
|
||||
@require_page("moderation")
|
||||
def update_moderation_event(event_id):
|
||||
if not can_write_page("moderation"):
|
||||
return render_template("403.html"), 403
|
||||
event = ModerationEvent.query.get_or_404(event_id)
|
||||
event.reason = request.form.get('reason')
|
||||
db.session.commit()
|
||||
return redirect(url_for('moderation'))
|
||||
|
||||
@webapp.route("/moderation/delete/<int:event_id>")
|
||||
@require_page("moderation")
|
||||
def delete_moderation_event(event_id):
|
||||
if not can_write_page("moderation"):
|
||||
return render_template("403.html"), 403
|
||||
event = ModerationEvent.query.get_or_404(event_id)
|
||||
db.session.delete(event)
|
||||
db.session.commit()
|
||||
return redirect(url_for('moderation'))
|
||||
|
||||
def _make_aware(dt):
|
||||
"""Convertit un datetime naive en aware (UTC)"""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
def get_invite_stats():
|
||||
"""Calcule les statistiques des invitations"""
|
||||
invites = DiscordInvite.query.filter_by(revoked=False).all()
|
||||
|
||||
if not invites:
|
||||
return {
|
||||
'total': 0,
|
||||
'total_uses': 0,
|
||||
'top_inviters': [],
|
||||
'permanent': 0,
|
||||
'temporary': 0,
|
||||
'expired': 0
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Comptages par inviteur
|
||||
inviter_uses = Counter()
|
||||
for inv in invites:
|
||||
if inv.inviter_name:
|
||||
inviter_uses[inv.inviter_name] += inv.uses or 0
|
||||
top_inviters = inviter_uses.most_common(5)
|
||||
|
||||
# Total des utilisations
|
||||
total_uses = sum(inv.uses or 0 for inv in invites)
|
||||
|
||||
# Invitations permanentes (max_age = 0) vs temporaires
|
||||
permanent = sum(1 for inv in invites if inv.max_age == 0)
|
||||
temporary = sum(1 for inv in invites if inv.max_age > 0)
|
||||
|
||||
# Invitations expirées (mais pas encore révoquées)
|
||||
expired = sum(1 for inv in invites if inv.expires_at and _make_aware(inv.expires_at) < now)
|
||||
|
||||
return {
|
||||
'total': len(invites),
|
||||
'total_uses': total_uses,
|
||||
'top_inviters': top_inviters,
|
||||
'permanent': permanent,
|
||||
'temporary': temporary,
|
||||
'expired': expired
|
||||
}
|
||||
|
||||
def is_invite_expired(invite, now):
|
||||
"""Vérifie si une invitation est expirée"""
|
||||
if not invite.expires_at:
|
||||
return False
|
||||
expires_at = _make_aware(invite.expires_at)
|
||||
return expires_at < now
|
||||
|
||||
@webapp.route("/moderation/invitations")
|
||||
def moderation_invitations():
|
||||
"""Affiche la liste des invitations Discord"""
|
||||
show_revoked = request.args.get('show_revoked', 'false') == 'true'
|
||||
|
||||
query = DiscordInvite.query
|
||||
if not show_revoked:
|
||||
query = query.filter_by(revoked=False)
|
||||
|
||||
invites = query.order_by(DiscordInvite.created_at.desc()).all()
|
||||
invite_stats = get_invite_stats()
|
||||
guilds = discord_bridge.get_guilds()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Pré-calculer le statut expiré pour chaque invitation
|
||||
for inv in invites:
|
||||
inv.is_expired = is_invite_expired(inv, now)
|
||||
|
||||
return render_template("moderation.html",
|
||||
events=[],
|
||||
event=None,
|
||||
stats=get_moderation_stats(),
|
||||
invites=invites,
|
||||
invite_stats=invite_stats,
|
||||
guilds=guilds,
|
||||
show_invites=True,
|
||||
show_revoked=show_revoked,
|
||||
now=now
|
||||
)
|
||||
|
||||
@webapp.route("/moderation/invitations/sync")
|
||||
def sync_invitations():
|
||||
"""Synchronise les invitations depuis Discord"""
|
||||
guild_id = request.args.get('guild_id', type=int)
|
||||
|
||||
result = discord_bridge.sync_invites(guild_id)
|
||||
|
||||
if result.get('success'):
|
||||
flash(f"✅ Synchronisation réussie : {result.get('synced', 0)} invitation(s) synchronisée(s)", 'success')
|
||||
else:
|
||||
flash(f"❌ Erreur : {result.get('message', 'Erreur inconnue')}", 'error')
|
||||
|
||||
return redirect(url_for('moderation_invitations'))
|
||||
|
||||
@webapp.route("/moderation/invitations/revoke/<invite_code>")
|
||||
def revoke_invitation(invite_code):
|
||||
"""Révoque une invitation Discord"""
|
||||
result = discord_bridge.revoke_invite(invite_code)
|
||||
|
||||
if result.get('success'):
|
||||
flash(f"✅ Invitation {invite_code} révoquée avec succès", 'success')
|
||||
else:
|
||||
flash(f"❌ Erreur : {result.get('message', 'Erreur inconnue')}", 'error')
|
||||
|
||||
return redirect(url_for('moderation_invitations'))
|
||||
|
||||
@webapp.route("/api/invitations/sync", methods=['POST'])
|
||||
def api_sync_invitations():
|
||||
"""API pour synchroniser les invitations"""
|
||||
guild_id = request.json.get('guild_id') if request.is_json else None
|
||||
result = discord_bridge.sync_invites(guild_id)
|
||||
return jsonify(result)
|
||||
|
||||
@webapp.route("/api/invitations/revoke/<invite_code>", methods=['POST'])
|
||||
def api_revoke_invitation(invite_code):
|
||||
"""API pour révoquer une invitation"""
|
||||
result = discord_bridge.revoke_invite(invite_code)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
+11
-3
@@ -1,23 +1,31 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import GameAlias
|
||||
from database.helpers import ConfigurationHelper
|
||||
|
||||
@webapp.route("/protondb")
|
||||
@require_page("protondb")
|
||||
def openProtonDB():
|
||||
aliases = GameAlias.query.all()
|
||||
return render_template("protondb.html", aliases = aliases, configuration = ConfigurationHelper())
|
||||
return render_template("protondb.html", aliases=aliases, configuration=ConfigurationHelper())
|
||||
|
||||
@webapp.route("/protondb/gamealias/add", methods=['POST'])
|
||||
@require_page("protondb")
|
||||
def addGameAlias():
|
||||
game_alias = GameAlias(alias = request.form.get('alias'), name = request.form.get('name'))
|
||||
if not can_write_page("protondb"):
|
||||
return render_template("403.html"), 403
|
||||
game_alias = GameAlias(alias=request.form.get('alias'), name=request.form.get('name'))
|
||||
db.session.add(game_alias)
|
||||
db.session.commit()
|
||||
return redirect(url_for('openProtonDB'))
|
||||
|
||||
@webapp.route('/protondb/gamealias/del/<int:id>')
|
||||
def delGameAlias(id : int):
|
||||
@require_page("protondb")
|
||||
def delGameAlias(id: int):
|
||||
if not can_write_page("protondb"):
|
||||
return render_template("403.html"), 403
|
||||
GameAlias.query.filter_by(id=id).delete()
|
||||
db.session.commit()
|
||||
return redirect(url_for('openProtonDB'))
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
# Paramètres webapp : rôles, permissions par page, inscriptions (super administrateur uniquement).
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page
|
||||
from database import db
|
||||
from database.models import WebappRole, PagePermission, WebappUser
|
||||
from database.helpers import ConfigurationHelper
|
||||
|
||||
# Métadonnées des pages : catégorie, label d'affichage, description
|
||||
PAGE_METADATA = {
|
||||
"index": {
|
||||
"label": "Tableau de bord",
|
||||
"category": "general",
|
||||
"description": "Page d'accueil avec aperçu du système",
|
||||
"icon": "home"
|
||||
},
|
||||
"commandes": {
|
||||
"label": "Commandes",
|
||||
"category": "content",
|
||||
"description": "Gérer les commandes Discord et Twitch",
|
||||
"icon": "terminal"
|
||||
},
|
||||
"configurations": {
|
||||
"label": "Configurations",
|
||||
"category": "config",
|
||||
"description": "Paramètres généraux du bot",
|
||||
"icon": "settings"
|
||||
},
|
||||
"humeurs": {
|
||||
"label": "Humeurs",
|
||||
"category": "content",
|
||||
"description": "Gérer les statuts du bot Discord",
|
||||
"icon": "smile"
|
||||
},
|
||||
"protondb": {
|
||||
"label": "ProtonDB",
|
||||
"category": "content",
|
||||
"description": "Recherche de compatibilité des jeux Linux",
|
||||
"icon": "gamepad"
|
||||
},
|
||||
"live_alert": {
|
||||
"label": "Alertes Live",
|
||||
"category": "content",
|
||||
"description": "Notifications Discord pour les streams Twitch",
|
||||
"icon": "bell"
|
||||
},
|
||||
"youtube": {
|
||||
"label": "YouTube",
|
||||
"category": "content",
|
||||
"description": "Notifications Discord pour les vidéos YouTube",
|
||||
"icon": "video"
|
||||
},
|
||||
"announcements": {
|
||||
"label": "Annonces Twitch",
|
||||
"category": "content",
|
||||
"description": "Messages automatiques dans le chat Twitch",
|
||||
"icon": "megaphone"
|
||||
},
|
||||
"moderation": {
|
||||
"label": "Modération Discord",
|
||||
"category": "moderation",
|
||||
"description": "Historique de modération Discord",
|
||||
"icon": "shield"
|
||||
},
|
||||
"twitch_moderation": {
|
||||
"label": "Modération Twitch",
|
||||
"category": "moderation",
|
||||
"description": "Commandes et logs de modération Twitch",
|
||||
"icon": "shield-check"
|
||||
},
|
||||
"link_filter": {
|
||||
"label": "Filtre de liens",
|
||||
"category": "moderation",
|
||||
"description": "Filtrage automatique des liens Twitch",
|
||||
"icon": "filter"
|
||||
},
|
||||
"twitch_events": {
|
||||
"label": "Événements Twitch",
|
||||
"category": "content",
|
||||
"description": "Notifications subs, follows, raids, clips",
|
||||
"icon": "star"
|
||||
},
|
||||
"freeloot": {
|
||||
"label": "Free Loot",
|
||||
"category": "content",
|
||||
"description": "Flux RSS de jeux gratuits vers Discord",
|
||||
"icon": "gift"
|
||||
},
|
||||
"users": {
|
||||
"label": "Utilisateurs",
|
||||
"category": "admin",
|
||||
"description": "Gestion des comptes et rôles webapp",
|
||||
"icon": "users"
|
||||
},
|
||||
"settings": {
|
||||
"label": "Paramètres",
|
||||
"category": "admin",
|
||||
"description": "Rôles, permissions et inscriptions",
|
||||
"icon": "cog"
|
||||
},
|
||||
}
|
||||
|
||||
# Labels des catégories
|
||||
CATEGORY_LABELS = {
|
||||
"general": {"label": "Général", "color": "#6B7280", "icon": "layout"},
|
||||
"content": {"label": "Contenu", "color": "#3B82F6", "icon": "file-text"},
|
||||
"moderation": {"label": "Modération", "color": "#EF4444", "icon": "shield"},
|
||||
"config": {"label": "Configuration", "color": "#8B5CF6", "icon": "settings"},
|
||||
"admin": {"label": "Administration", "color": "#F59E0B", "icon": "crown"},
|
||||
}
|
||||
|
||||
# Rôles par défaut avec métadonnées
|
||||
DEFAULT_ROLES = {
|
||||
"viewer_twitch": {
|
||||
"description": "Accès minimal, consultation uniquement",
|
||||
"color": "#9146FF",
|
||||
"icon": "eye"
|
||||
},
|
||||
"utilisateur_discord": {
|
||||
"description": "Peut consulter et modifier du contenu basique",
|
||||
"color": "#5865F2",
|
||||
"icon": "user"
|
||||
},
|
||||
"moderateur_discord": {
|
||||
"description": "Accès aux outils de modération Discord",
|
||||
"color": "#57F287",
|
||||
"icon": "shield"
|
||||
},
|
||||
"expert_discord": {
|
||||
"description": "Gestion avancée du contenu et des configurations",
|
||||
"color": "#FEE75C",
|
||||
"icon": "star"
|
||||
},
|
||||
"moderateur_twitch": {
|
||||
"description": "Accès aux outils de modération Twitch",
|
||||
"color": "#9146FF",
|
||||
"icon": "shield-check"
|
||||
},
|
||||
"super_administrateur": {
|
||||
"description": "Accès complet à toutes les fonctionnalités",
|
||||
"color": "#ED4245",
|
||||
"icon": "crown"
|
||||
},
|
||||
}
|
||||
|
||||
PAGE_KEYS = [
|
||||
("index", "Tableau de bord"),
|
||||
("configurations", "Configurations"),
|
||||
("commandes", "Commandes"),
|
||||
("humeurs", "Humeurs"),
|
||||
("live_alert", "Alerte live"),
|
||||
("announcements", "Annonces Twitch"),
|
||||
("twitch_moderation", "Modération Twitch"),
|
||||
("link_filter", "Filtre de liens"),
|
||||
("twitch_events", "Notifications événements Twitch"),
|
||||
("youtube", "YouTube"),
|
||||
("protondb", "ProtonDB"),
|
||||
("freeloot", "FreeLoot"),
|
||||
("moderation", "Modération Discord"),
|
||||
("users", "Utilisateurs"),
|
||||
("settings", "Paramètres"),
|
||||
]
|
||||
|
||||
|
||||
@webapp.route("/settings")
|
||||
@require_page("settings")
|
||||
def settings():
|
||||
roles = WebappRole.query.order_by(WebappRole.level).all()
|
||||
permissions = PagePermission.query.all()
|
||||
perm_by_key = {p.page_key: p for p in permissions}
|
||||
reg_enabled = ConfigurationHelper().getValue("registration_enabled") not in (None, "", "false", "0", "no", "off")
|
||||
|
||||
# Organiser les pages par catégorie
|
||||
pages_by_category = {}
|
||||
for page_key, meta in PAGE_METADATA.items():
|
||||
category = meta.get("category", "general")
|
||||
if category not in pages_by_category:
|
||||
pages_by_category[category] = []
|
||||
pages_by_category[category].append({
|
||||
"key": page_key,
|
||||
"meta": meta,
|
||||
"permission": perm_by_key.get(page_key)
|
||||
})
|
||||
|
||||
# Trier les pages dans chaque catégorie par label
|
||||
for category in pages_by_category:
|
||||
pages_by_category[category].sort(key=lambda x: x["meta"]["label"])
|
||||
|
||||
return render_template(
|
||||
"settings.html",
|
||||
roles=roles,
|
||||
pages_by_category=pages_by_category,
|
||||
category_labels=CATEGORY_LABELS,
|
||||
perm_by_key=perm_by_key,
|
||||
registration_enabled=reg_enabled,
|
||||
page_metadata=PAGE_METADATA,
|
||||
default_roles_meta=DEFAULT_ROLES,
|
||||
)
|
||||
|
||||
|
||||
@webapp.route("/settings/registration", methods=["POST"])
|
||||
@require_page("settings")
|
||||
def settings_toggle_registration():
|
||||
enabled = request.form.get("enabled") in ("1", "true", "on", "yes")
|
||||
ConfigurationHelper().createOrUpdate("registration_enabled", "true" if enabled else "false")
|
||||
db.session.commit()
|
||||
flash("Inscriptions " + ("activées" if enabled else "désactivées") + ".", "success")
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
|
||||
@webapp.route("/settings/roles/add", methods=["POST"])
|
||||
@require_page("settings")
|
||||
def settings_role_add():
|
||||
name = (request.form.get("name") or "").strip()
|
||||
level_str = request.form.get("level", "0")
|
||||
description = (request.form.get("description") or "").strip()
|
||||
color = (request.form.get("color") or "#6B7280").strip()
|
||||
icon = (request.form.get("icon") or "").strip()
|
||||
|
||||
if not name:
|
||||
flash("Nom du rôle requis.", "error")
|
||||
return redirect(url_for("settings"))
|
||||
try:
|
||||
level = int(level_str)
|
||||
except ValueError:
|
||||
level = 0
|
||||
if WebappRole.query.filter_by(name=name).first():
|
||||
flash(f"Le rôle « {name} » existe déjà.", "error")
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
role = WebappRole(
|
||||
name=name,
|
||||
level=level,
|
||||
description=description if description else None,
|
||||
color=color,
|
||||
icon=icon if icon else None
|
||||
)
|
||||
db.session.add(role)
|
||||
db.session.commit()
|
||||
flash(f"Rôle « {name} » créé.", "success")
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
|
||||
@webapp.route("/settings/roles/<int:role_id>/edit", methods=["POST"])
|
||||
@require_page("settings")
|
||||
def settings_role_edit(role_id):
|
||||
role = WebappRole.query.get_or_404(role_id)
|
||||
level_str = request.form.get("level")
|
||||
description = request.form.get("description")
|
||||
color = request.form.get("color")
|
||||
icon = request.form.get("icon")
|
||||
|
||||
if level_str is not None:
|
||||
try:
|
||||
role.level = int(level_str)
|
||||
except ValueError:
|
||||
flash("Niveau invalide.", "error")
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
if description is not None:
|
||||
role.description = description.strip() if description.strip() else None
|
||||
if color is not None:
|
||||
role.color = color.strip() if color.strip() else "#6B7280"
|
||||
if icon is not None:
|
||||
role.icon = icon.strip() if icon.strip() else None
|
||||
|
||||
db.session.commit()
|
||||
flash(f"Rôle « {role.name} » mis à jour.", "success")
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
|
||||
@webapp.route("/settings/roles/<int:role_id>/delete", methods=["POST"])
|
||||
@require_page("settings")
|
||||
def settings_role_delete(role_id):
|
||||
role = WebappRole.query.get_or_404(role_id)
|
||||
if WebappUser.query.filter_by(role=role.name).count() > 0:
|
||||
flash(f"Impossible de supprimer le rôle « {role.name} » : des utilisateurs l'utilisent.", "error")
|
||||
return redirect(url_for("settings"))
|
||||
db.session.delete(role)
|
||||
db.session.commit()
|
||||
flash(f"Rôle « {role.name} » supprimé.", "success")
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
|
||||
@webapp.route("/settings/permissions/update", methods=["POST"])
|
||||
@require_page("settings")
|
||||
def settings_permissions_update():
|
||||
page_key = request.form.get("page_key")
|
||||
role_name = request.form.get("role")
|
||||
if not page_key:
|
||||
return redirect(url_for("settings"))
|
||||
role = WebappRole.query.filter_by(name=role_name).first()
|
||||
level = role.level if role else 0
|
||||
perm = PagePermission.query.filter_by(page_key=page_key).first()
|
||||
if perm:
|
||||
perm.min_level = level
|
||||
perm.write_level = level
|
||||
else:
|
||||
perm = PagePermission(page_key=page_key, min_level=level, write_level=level)
|
||||
db.session.add(perm)
|
||||
db.session.commit()
|
||||
flash(f"Accès à « {page_key} » mis à jour.", "success")
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
|
||||
@webapp.route("/settings/permissions/bulk", methods=["POST"])
|
||||
@require_page("settings")
|
||||
def settings_permissions_bulk():
|
||||
page_keys = request.form.getlist("page_keys")
|
||||
role_name = request.form.get("role")
|
||||
if not page_keys or not role_name:
|
||||
flash("Sélectionnez au moins une page et un rôle.", "error")
|
||||
return redirect(url_for("settings"))
|
||||
role = WebappRole.query.filter_by(name=role_name).first()
|
||||
level = role.level if role else 0
|
||||
updated = 0
|
||||
for page_key in page_keys:
|
||||
perm = PagePermission.query.filter_by(page_key=page_key).first()
|
||||
if perm:
|
||||
perm.min_level = level
|
||||
perm.write_level = level
|
||||
else:
|
||||
perm = PagePermission(page_key=page_key, min_level=level, write_level=level)
|
||||
db.session.add(perm)
|
||||
updated += 1
|
||||
db.session.commit()
|
||||
flash(f"Accès mis à jour pour {updated} page(s) avec le rôle « {role_name} ».", "success")
|
||||
return redirect(url_for("settings"))
|
||||
@@ -0,0 +1,603 @@
|
||||
/* MVP.css v1.17.2 - https://github.com/andybrewer/mvp */
|
||||
|
||||
:root {
|
||||
--active-brightness: 0.85;
|
||||
--border-radius: 5px;
|
||||
--box-shadow: 2px 2px 10px;
|
||||
--color-accent: #118bee15;
|
||||
--color-bg: #fff;
|
||||
--color-bg-secondary: #e9e9e9;
|
||||
--color-link: #118bee;
|
||||
--color-secondary: #920de9;
|
||||
--color-secondary-accent: #920de90b;
|
||||
--color-shadow: #f4f4f4;
|
||||
--color-table: #118bee;
|
||||
--color-text: #000;
|
||||
--color-text-secondary: #999;
|
||||
--color-scrollbar: #cacae8;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
--hover-brightness: 1.2;
|
||||
--justify-important: center;
|
||||
--justify-normal: left;
|
||||
--line-height: 1.5;
|
||||
--width-card: 285px;
|
||||
--width-card-medium: 460px;
|
||||
--width-card-wide: 800px;
|
||||
--width-content: 1080px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[color-mode="user"] {
|
||||
--color-accent: #0097fc4f;
|
||||
--color-bg: #333;
|
||||
--color-bg-secondary: #555;
|
||||
--color-link: #0097fc;
|
||||
--color-secondary: #e20de9;
|
||||
--color-secondary-accent: #e20de94f;
|
||||
--color-shadow: #bbbbbb20;
|
||||
--color-table: #0097fc;
|
||||
--color-text: #f7f7f7;
|
||||
--color-text-secondary: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
article aside {
|
||||
background: var(--color-secondary-accent);
|
||||
border-left: 4px solid var(--color-secondary);
|
||||
padding: 0.01rem 0.8rem;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-family);
|
||||
line-height: var(--line-height);
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
footer,
|
||||
header,
|
||||
main {
|
||||
margin: 0 auto;
|
||||
max-width: var(--width-content);
|
||||
/* padding: 3rem 1rem; */
|
||||
padding: 1rem 1rem;
|
||||
}
|
||||
|
||||
hr {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: none;
|
||||
height: 1px;
|
||||
margin: 4rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: var(--justify-important);
|
||||
}
|
||||
|
||||
section img,
|
||||
article img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
section pre {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
section aside {
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow) var(--color-shadow);
|
||||
margin: 1rem;
|
||||
padding: 1.25rem;
|
||||
width: var(--width-card);
|
||||
}
|
||||
|
||||
section aside:hover {
|
||||
box-shadow: var(--box-shadow) var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Headers */
|
||||
article header,
|
||||
div header,
|
||||
main header {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: var(--justify-important);
|
||||
}
|
||||
|
||||
header a b,
|
||||
header a em,
|
||||
header a i,
|
||||
header a strong {
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* header nav img {
|
||||
margin: 1rem 0;
|
||||
} */
|
||||
|
||||
section header {
|
||||
padding-top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Nav */
|
||||
nav {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-weight: bold;
|
||||
justify-content: space-between;
|
||||
/* margin-bottom: 7rem; */
|
||||
}
|
||||
|
||||
nav ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
nav ul li {
|
||||
display: inline-block;
|
||||
margin: 0 0.5rem;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Nav Dropdown */
|
||||
nav ul li:hover ul {
|
||||
display: block;
|
||||
}
|
||||
|
||||
nav ul li ul {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow) var(--color-shadow);
|
||||
display: none;
|
||||
height: auto;
|
||||
left: -2px;
|
||||
padding: 0.5rem 1rem;
|
||||
position: absolute;
|
||||
top: 1.7rem;
|
||||
white-space: nowrap;
|
||||
width: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
nav ul li ul::before {
|
||||
/* fill gap above to make mousing over them easier */
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -0.5rem;
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
nav ul li ul li,
|
||||
nav ul li ul li a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Nav for Mobile */
|
||||
@media (max-width: 768px) {
|
||||
nav {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
nav ul li {
|
||||
width: calc(100% - 1em);
|
||||
}
|
||||
|
||||
nav ul li ul {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
display: block;
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
code,
|
||||
samp {
|
||||
background-color: var(--color-accent);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-text);
|
||||
display: inline-block;
|
||||
margin: 0 0.1rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
details {
|
||||
margin: 1.3rem 0;
|
||||
}
|
||||
|
||||
details summary {
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: var(--line-height);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.1rem;
|
||||
}
|
||||
|
||||
ol li,
|
||||
ul li {
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.75rem 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 1rem 0;
|
||||
max-width: var(--width-card-wide);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
pre code,
|
||||
pre samp {
|
||||
display: block;
|
||||
max-width: var(--width-card-wide);
|
||||
padding: 0.5rem 2rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
sup {
|
||||
background-color: var(--color-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-bg);
|
||||
font-size: xx-small;
|
||||
font-weight: bold;
|
||||
margin: 0.2rem;
|
||||
padding: 0.2rem 0.3rem;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: var(--color-link);
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
filter: brightness(var(--hover-brightness));
|
||||
}
|
||||
|
||||
a:active {
|
||||
filter: brightness(var(--active-brightness));
|
||||
}
|
||||
|
||||
a b,
|
||||
a em,
|
||||
a i,
|
||||
a strong,
|
||||
button,
|
||||
input[type="submit"] {
|
||||
border-radius: var(--border-radius);
|
||||
display: inline-block;
|
||||
font-size: medium;
|
||||
font-weight: bold;
|
||||
line-height: var(--line-height);
|
||||
margin: 0.5rem 0;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
button,
|
||||
input[type="submit"] {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
button:hover,
|
||||
input[type="submit"]:hover {
|
||||
cursor: pointer;
|
||||
filter: brightness(var(--hover-brightness));
|
||||
}
|
||||
|
||||
button:active,
|
||||
input[type="submit"]:active {
|
||||
filter: brightness(var(--active-brightness));
|
||||
}
|
||||
|
||||
a b,
|
||||
a strong,
|
||||
button,
|
||||
input[type="submit"] {
|
||||
background-color: var(--color-link);
|
||||
border: 2px solid var(--color-link);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
a em,
|
||||
a i {
|
||||
border: 2px solid var(--color-link);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-link);
|
||||
display: inline-block;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
article aside a {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
/* Images */
|
||||
figure {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
figure img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
figure figcaption {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
button:disabled,
|
||||
input:disabled {
|
||||
background: var(--color-bg-secondary);
|
||||
border-color: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button[disabled]:hover,
|
||||
input[type="submit"][disabled]:hover {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
form {
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow) var(--color-shadow);
|
||||
display: block;
|
||||
max-width: var(--width-card-wide);
|
||||
min-width: var(--width-card);
|
||||
padding: 1.5rem;
|
||||
text-align: var(--justify-normal);
|
||||
}
|
||||
|
||||
form header {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
input,
|
||||
label,
|
||||
select,
|
||||
textarea {
|
||||
display: block;
|
||||
font-size: inherit;
|
||||
max-width: var(--width-card-wide);
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
input[type="checkbox"]+label,
|
||||
input[type="radio"]+label {
|
||||
display: inline-block;
|
||||
font-weight: normal;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="email"],
|
||||
textarea {
|
||||
width: calc(100% - 1.6rem);
|
||||
}
|
||||
|
||||
input[readonly],
|
||||
textarea[readonly] {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
/* Popups */
|
||||
dialog {
|
||||
max-width: 90%;
|
||||
max-height: 85dvh;
|
||||
margin: auto;
|
||||
padding-block: 0;
|
||||
padding-inline: 20px;
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: 0.5rem;
|
||||
overscroll-behavior: contain;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
/* Hide scrollbar for Firefox */
|
||||
-ms-overflow-style: none;
|
||||
/* Hide scrollbar for IE and Edge */
|
||||
scrollbar-color: transparent transparent;
|
||||
animation: bottom-to-top 0.25s ease-in-out forwards;
|
||||
}
|
||||
|
||||
dialog::-webkit-scrollbar {
|
||||
width: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
dialog::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
dialog::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@media (min-width: 650px) {
|
||||
dialog {
|
||||
max-width: 39rem;
|
||||
}
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@keyframes bottom-to-top {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10%);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
dialog hr {
|
||||
margin-block: 1rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
border-spacing: 0;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
table td,
|
||||
table th,
|
||||
table tr {
|
||||
padding: 0.4rem 0.8rem;
|
||||
text-align: var(--justify-important);
|
||||
}
|
||||
|
||||
table thead {
|
||||
background-color: var(--color-table);
|
||||
border-collapse: collapse;
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-bg);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
table thead tr:first-child th:first-child {
|
||||
border-top-left-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
table thead tr:first-child th:last-child {
|
||||
border-top-right-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
table thead th:first-child,
|
||||
table tr td:first-child {
|
||||
text-align: var(--justify-normal);
|
||||
}
|
||||
|
||||
table tr:nth-child(even) {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Quotes */
|
||||
blockquote {
|
||||
display: block;
|
||||
font-size: x-large;
|
||||
line-height: var(--line-height);
|
||||
margin: 1rem auto;
|
||||
max-width: var(--width-card-medium);
|
||||
padding: 1.5rem 1rem;
|
||||
text-align: var(--justify-important);
|
||||
}
|
||||
|
||||
blockquote footer {
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
font-size: small;
|
||||
line-height: var(--line-height);
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-scrollbar) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-scrollbar);
|
||||
border-radius: 10px;
|
||||
}
|
||||
+7
-1953
File diff suppressed because it is too large
Load Diff
@@ -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 %}
|
||||
@@ -0,0 +1,188 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Annonces Twitch</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">Messages automatiques périodiques dans le chat</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Configurez des messages envoyés automatiquement dans le chat Twitch.
|
||||
L'annonce est envoyée uniquement si le temps est écoulé ET si le nombre minimum de messages a été atteint.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if not announcement %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Annonces configurées</h2>
|
||||
</div>
|
||||
|
||||
{% if announcements %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Nom</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Temps</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Min. messages</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Dernier envoi</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for ann in announcements %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ ann.name }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-gray-600 dark:text-gray-300 text-sm max-w-xs truncate block">{{ ann.text[:60] }}{% if ann.text|length > 60 %}...{% endif %}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
{{ ann.periodicity }} min
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||
{{ ann.min_chat_messages }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if ann.last_sent %}
|
||||
{{ ann.last_sent.strftime('%d/%m %H:%M') }}
|
||||
{% else %}
|
||||
<span class="italic">Jamais</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a href="{{ url_for('toggleAnnouncement', id=ann.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title="{{ 'Désactiver' if ann.enable else 'Activer' }}">
|
||||
{% if ann.enable %}
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="{{ url_for('resetAnnouncement', id=ann.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Remettre le compteur à zéro">
|
||||
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{{ url_for('openEditAnnouncement', id=ann.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Modifier">
|
||||
<svg class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{{ url_for('delAnnouncement', id=ann.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette annonce ?')"
|
||||
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
|
||||
title="Supprimer">
|
||||
<svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-6 py-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">Aucune annonce</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Commencez par créer votre première annonce automatique.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-6">
|
||||
{{ 'Modifier l\'annonce' if announcement else 'Ajouter une annonce' }}
|
||||
</h2>
|
||||
|
||||
<form action="{{ url_for('submitEditAnnouncement', id=announcement.id) if announcement else url_for('addAnnouncement') }}" method="POST" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Nom de l'annonce
|
||||
</label>
|
||||
<input type="text" name="name" id="name" required maxlength="64"
|
||||
value="{{ announcement.name if announcement else '' }}"
|
||||
placeholder="Ex: Règles du chat"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="periodicity" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Temps entre les annonces (minutes)
|
||||
</label>
|
||||
<input type="number" name="periodicity" id="periodicity" required min="1" max="1440"
|
||||
value="{{ announcement.periodicity if announcement else 10 }}"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">1 min à 1440 min (24h)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="min_chat_messages" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Messages minimum entre annonces
|
||||
</label>
|
||||
<input type="number" name="min_chat_messages" id="min_chat_messages" required min="0" max="1000"
|
||||
value="{{ announcement.min_chat_messages if announcement else 0 }}"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">0 = pas de minimum</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="text" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Message
|
||||
</label>
|
||||
<textarea name="text" id="text" required maxlength="500" rows="4"
|
||||
placeholder="Le message qui sera envoyé dans le chat..."
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors resize-none">{{ announcement.text if announcement else '' }}</textarea>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Maximum 500 caractères</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2">
|
||||
{{ 'Enregistrer' if announcement else 'Ajouter' }}
|
||||
</button>
|
||||
{% if announcement %}
|
||||
<a href="{{ url_for('openAnnouncements') }}"
|
||||
class="px-6 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-lg transition-colors">
|
||||
Annuler
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
+125
-50
@@ -1,55 +1,130 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Commandes de Mamie</h1>
|
||||
<p>Gérez les commandes personnalisées du bot. Ces commandes peuvent être activées sur Discord et/ou Twitch selon vos besoins.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Commande</th>
|
||||
<th>Réponse</th>
|
||||
<th>Discord</th>
|
||||
<th>Twitch</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for commande in commandes %}
|
||||
<tr>
|
||||
<td>{{ commande.trigger }}</td>
|
||||
<td>{{ commande.response }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('toggle_discord_commande', commande_id = commande.id) }}" class="icon">
|
||||
{{ '✅' if commande.discord_enable else '❌' }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('toggle_twitch_commande', commande_id = commande.id) }}" class="icon">
|
||||
{{ '✅' if commande.twitch_enable else '❌' }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('delete_commande', commande_id = commande.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette commande ?')">Supprimer</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Commandes de Mamie</h1>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Gérez les commandes personnalisées du bot. Ces commandes peuvent être activées sur Discord et/ou Twitch selon vos besoins.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Ajouter une commande</h2>
|
||||
<form action="{{ url_for('add_commande') }}" method="POST">
|
||||
<label for="trigger">Commande</label>
|
||||
<input name="trigger" type="text" />
|
||||
<label for="response">Réponse</label>
|
||||
<textarea name="response" rows="5" cols="50"></textarea>
|
||||
<div>
|
||||
<label for="discord_enable">Discord</label>
|
||||
<input name="discord_enable" type="checkbox" checked />
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Liste des commandes</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Commande</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Réponse</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Discord</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Twitch</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Permission Twitch</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for commande in commandes %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-purple-600 dark:text-purple-400 font-mono">{{ commande.trigger }}</code>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-600 dark:text-gray-400 max-w-md truncate">{{ commande.response }}</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<a href="{{ url_for('toggle_discord_commande', commande_id = commande.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors inline-block"
|
||||
title="{{ 'Désactiver sur Discord' if commande.discord_enable else 'Activer sur Discord' }}">
|
||||
{{ '✅' if commande.discord_enable else '❌' }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<a href="{{ url_for('toggle_twitch_commande', commande_id = commande.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors inline-block"
|
||||
title="{{ 'Désactiver sur Twitch' if commande.twitch_enable else 'Activer sur Twitch' }}">
|
||||
{{ '✅' if commande.twitch_enable else '❌' }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
{% if commande.twitch_enable %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
|
||||
{{ twitch_permissions.get(commande.twitch_permission or 'viewer', 'Tous') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-500">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<a href="{{ url_for('delete_commande', commande_id = commande.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette commande ?')"
|
||||
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400 inline-block"
|
||||
title="Supprimer">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
Aucune commande configurée. Ajoutez-en une ci-dessous.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="twitch_enable">Twitch</label>
|
||||
<input name="twitch_enable" type="checkbox" unchecked />
|
||||
</div>
|
||||
<input type="Submit" value="Ajouter">
|
||||
</form>
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Ajouter une commande</h2>
|
||||
|
||||
<form action="{{ url_for('add_commande') }}" method="POST" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="trigger" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Commande</label>
|
||||
<input name="trigger" id="trigger" type="text" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="!macommande"/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-end gap-6">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input name="discord_enable" type="checkbox" checked
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Discord</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input name="twitch_enable" type="checkbox" id="twitch_enable_checkbox"
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Twitch</span>
|
||||
</label>
|
||||
<div class="w-full sm:w-auto">
|
||||
<label for="twitch_permission" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Permission Twitch (qui peut utiliser la commande)</label>
|
||||
<select name="twitch_permission" id="twitch_permission"
|
||||
class="w-full sm:w-48 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
{% for value, label in twitch_permissions.items() %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="response" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Réponse</label>
|
||||
<textarea name="response" id="response" rows="4" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all resize-y"
|
||||
placeholder="La réponse que le bot enverra..."></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Ajouter la commande
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,247 +1,172 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="config-page">
|
||||
<h1>⚙️ Configuration</h1>
|
||||
<p>Paramétrez Discord, Twitch, YouTube et les fonctionnalités du bot.</p>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-4 space-y-2">
|
||||
{% for category, msg in messages %}
|
||||
<div class="p-4 rounded-lg {{ 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200' if category == 'success' else 'bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200' }}">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Configuration de Mamie</h1>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Configurez les tokens Discord, les notifications Humble Bundle et l'API Twitch.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Discord -->
|
||||
<div class="config-section open" data-section="discord">
|
||||
<div class="config-section-header" onclick="toggleSection(this)">
|
||||
<div class="config-section-icon">🎮</div>
|
||||
<div class="config-section-title">
|
||||
<h2>Discord</h2>
|
||||
<p>Token API, messages de bienvenue, auto-rôle et modération</p>
|
||||
</div>
|
||||
<svg class="config-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
<div class="space-y-8">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<svg class="w-6 h-6 text-indigo-500" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Discord</h2>
|
||||
</div>
|
||||
<div class="config-section-content">
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-6">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">API Discord</h3>
|
||||
<div>
|
||||
<label for="discord_token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Token Discord (caché)</label>
|
||||
<input name="discord_token" id="discord_token" type="password"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
||||
placeholder="Votre token Discord"/>
|
||||
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">Nécessite un redémarrage après modification</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Messages de bienvenue</h3>
|
||||
|
||||
<!-- Token Discord -->
|
||||
<div class="config-card">
|
||||
<div class="config-card-header">
|
||||
<h3>🔑 Token Discord</h3>
|
||||
<span class="badge">Sensible</span>
|
||||
</div>
|
||||
<label for="discord_token">Token du bot (caché)</label>
|
||||
<div class="token-input-wrapper">
|
||||
<input name="discord_token" id="discord_token" type="password" placeholder="Votre token Discord" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" onclick="toggleTokenVisibility('discord_token', this)" title="Afficher/Masquer">👁️</button>
|
||||
</div>
|
||||
<small>⚠️ Nécessite un redémarrage du bot après modification</small>
|
||||
</div>
|
||||
|
||||
<!-- Messages de bienvenue -->
|
||||
<div class="config-card">
|
||||
<div class="config-card-header">
|
||||
<h3>👋 Messages de bienvenue</h3>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label for="welcome_enable">Activer le message de bienvenue pour les nouveaux membres</label>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" name="welcome_enable" id="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="welcome_channel_id">Canal de bienvenue</label>
|
||||
<select name="welcome_channel_id" id="welcome_channel_id">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer le message de bienvenue pour les nouveaux membres</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label for="welcome_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de bienvenue</label>
|
||||
<select name="welcome_channel_id" id="welcome_channel_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('welcome_channel_id')==channel.id %}selected{% endif %}>
|
||||
#{{channel.name}}
|
||||
</option>
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('welcome_channel_id')==channel.id %}selected{% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="welcome_message">Message personnalisé</label>
|
||||
<textarea name="welcome_message" id="welcome_message" rows="3" placeholder="Bienvenue {member.mention} sur le serveur !">{{ configuration.getValue('welcome_message') }}</textarea>
|
||||
|
||||
<details class="syntax-helper">
|
||||
<summary>📝 Variables disponibles</summary>
|
||||
<div class="syntax-list">
|
||||
<div class="syntax-item">
|
||||
<code>{member.mention}</code>
|
||||
<span>Mentionne l'utilisateur (@NomUtilisateur)</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{member.name}</code>
|
||||
<span>Nom d'utilisateur (sans mention)</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{member.display_name}</code>
|
||||
<span>Surnom sur le serveur</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{member.id}</code>
|
||||
<span>ID de l'utilisateur</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{server.name}</code>
|
||||
<span>Nom du serveur</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{server.member_count}</code>
|
||||
<span>Nombre total de membres</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code><#ID_DU_CHANNEL></code>
|
||||
<span>Mentionne un salon</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Auto-Role -->
|
||||
<div class="config-card">
|
||||
<div class="config-card-header">
|
||||
<h3>🎭 Auto-Rôle</h3>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label for="autorole_enable">Attribuer automatiquement un rôle aux nouveaux membres</label>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" name="autorole_enable" id="autorole_enable" {% if configuration.getValue('autorole_enable') %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>Rôle à attribuer</label>
|
||||
{% if roles|length > 1 %}
|
||||
<div class="guild-tabs">
|
||||
{% for guild_data in roles %}
|
||||
<button type="button" class="guild-tab {% if loop.first %}active{% endif %}" onclick="switchGuildTab(this, 'autorole-guild-{{guild_data.guild_id}}')">
|
||||
{{ guild_data.guild_name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for guild_data in roles %}
|
||||
<div id="autorole-guild-{{guild_data.guild_id}}" class="guild-content {% if loop.first %}active{% endif %}">
|
||||
<select name="autorole_role_id">
|
||||
<option value="">-- Aucun rôle --</option>
|
||||
{% for role in guild_data.roles %}
|
||||
<option value="{{role.id}}" {% if configuration.getIntValue('autorole_role_id')==role.id %}selected{% endif %}>
|
||||
{% if role.color.value != 0 %}⬤{% else %}○{% endif %} {{role.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="warning-box">
|
||||
<span class="warning-box-icon">⚠️</span>
|
||||
<div class="warning-box-content">
|
||||
<strong>Permissions requises</strong>
|
||||
<p>Le bot doit avoir "Gérer les rôles" et son rôle doit être plus haut dans la hiérarchie que le rôle à attribuer.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="welcome_message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message personnalisé</label>
|
||||
<textarea name="welcome_message" id="welcome_message" rows="3"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all resize-y"
|
||||
placeholder="Bienvenue {member.mention} sur le serveur !">{{ configuration.getValue('welcome_message') }}</textarea>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<p><code class="px-1 bg-gray-200 dark:bg-gray-600 rounded">{member.mention}</code> Mentionne l'utilisateur</p>
|
||||
<p><code class="px-1 bg-gray-200 dark:bg-gray-600 rounded">{member.name}</code> Nom d'utilisateur</p>
|
||||
<p><code class="px-1 bg-gray-200 dark:bg-gray-600 rounded">{server.name}</code> Nom du serveur</p>
|
||||
<p><code class="px-1 bg-gray-200 dark:bg-gray-600 rounded">{server.member_count}</code> Nombre de membres</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages de départ -->
|
||||
<div class="config-card">
|
||||
<div class="config-card-header">
|
||||
<h3>🚪 Messages de départ</h3>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label for="leave_enable">Activer le message quand un membre quitte le serveur</label>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" name="leave_enable" id="leave_enable" {% if configuration.getValue('leave_enable') %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="leave_channel_id">Canal de départ</label>
|
||||
<select name="leave_channel_id" id="leave_channel_id">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Messages de départ</h3>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="leave_enable" {% if configuration.getValue('leave_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer le message quand un membre quitte le serveur</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label for="leave_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de départ</label>
|
||||
<select name="leave_channel_id" id="leave_channel_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('leave_channel_id')==channel.id %}selected{% endif %}>
|
||||
#{{channel.name}}
|
||||
</option>
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('leave_channel_id')==channel.id %}selected{% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="leave_message">Message personnalisé</label>
|
||||
<textarea name="leave_message" id="leave_message" rows="3" placeholder="{member.name} a quitté le serveur.">{{ configuration.getValue('leave_message') }}</textarea>
|
||||
|
||||
<details class="syntax-helper">
|
||||
<summary>📝 Variables disponibles</summary>
|
||||
<div class="syntax-list">
|
||||
<div class="syntax-item">
|
||||
<code>{member.mention}</code>
|
||||
<span>Mentionne l'utilisateur</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{member.name}</code>
|
||||
<span>Nom d'utilisateur</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{member.display_name}</code>
|
||||
<span>Surnom sur le serveur</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{server.name}</code>
|
||||
<span>Nom du serveur</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{server.member_count}</code>
|
||||
<span>Nombre de membres restants</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="leave_message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message personnalisé</label>
|
||||
<textarea name="leave_message" id="leave_message" rows="3"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all resize-y"
|
||||
placeholder="{member.name} a quitté le serveur.">{{ configuration.getValue('leave_message') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modération -->
|
||||
<div class="config-card">
|
||||
<div class="config-card-header">
|
||||
<h3>🛡️ Modération</h3>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label for="moderation_enable">Activer les commandes d'avertissement (!warn, !unwarn, !inspect)</label>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" name="moderation_enable" id="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label for="moderation_ban_enable">Activer les commandes de bannissement (!ban, !unban)</label>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" name="moderation_ban_enable" id="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label for="moderation_kick_enable">Activer la commande d'expulsion (!kick)</label>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" name="moderation_kick_enable" id="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="moderation_log_channel_id">Canal de logs de modération</label>
|
||||
<select name="moderation_log_channel_id" id="moderation_log_channel_id">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('moderation_log_channel_id')==channel.id %}selected{% endif %}>
|
||||
#{{channel.name}}
|
||||
</option>
|
||||
<div id="auto-rooms" class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Auto Rooms (salons vocaux temporaires)</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Quand un membre rejoint le canal vocal configuré ci-dessous, un salon vocal temporaire est créé. Le message de configuration avec les réactions apparaît dans la <strong>partie texte du vocal</strong> (onglet Discussion à droite quand on ouvre le salon). Seul le propriétaire peut réagir.
|
||||
</p>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="auto_rooms_enable" {% if configuration.getValue('auto_rooms_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les Auto Rooms</span>
|
||||
</label>
|
||||
<div>
|
||||
<label for="auto_rooms_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal vocal à rejoindre pour créer un salon</label>
|
||||
<select name="auto_rooms_channel_id" id="auto_rooms_channel_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
|
||||
<option value="">— Choisir un canal vocal —</option>
|
||||
{% for channel in voice_channels %}
|
||||
<option value="{{ channel.id }}" {% if configuration.getIntValue('auto_rooms_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }} (vocal)</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>Toutes les actions de modération seront notifiées dans ce canal</small>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Ex. « + Créer votre salon » — les membres qui rejoignent ce canal obtiennent un salon vocal dédié.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Modération</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les commandes d'avertissement (!warn, !unwarn, !inspect)</span>
|
||||
</label>
|
||||
|
||||
<label>Rôles Staff autorisés</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les commandes de bannissement (!ban, !unban)</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer la commande d'expulsion (!kick)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="moderation_log_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de logs de modération</label>
|
||||
<select name="moderation_log_channel_id" id="moderation_log_channel_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('moderation_log_channel_id')==channel.id %}selected{% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Toutes les actions de modération seront notifiées dans ce canal</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôles Staff autorisés</label>
|
||||
{% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %}
|
||||
|
||||
{% if roles|length > 1 %}
|
||||
<div class="guild-tabs">
|
||||
<div class="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-600 mb-3">
|
||||
{% for guild_data in roles %}
|
||||
<button type="button" class="guild-tab {% if loop.first %}active{% endif %}" onclick="switchGuildTab(this, 'moderation-guild-{{guild_data.guild_id}}')">
|
||||
<button type="button" class="tab-button px-4 py-2 text-sm font-medium rounded-t-lg transition-colors
|
||||
{% if loop.first %}bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}"
|
||||
onclick="openTab(event, 'guild-{{guild_data.guild_id}}')" {% if loop.first %}id="defaultOpen"{% endif %}>
|
||||
{{ guild_data.guild_name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
@@ -249,228 +174,168 @@
|
||||
{% endif %}
|
||||
|
||||
{% for guild_data in roles %}
|
||||
<div id="moderation-guild-{{guild_data.guild_id}}" class="guild-content {% if loop.first %}active{% endif %}">
|
||||
<div class="roles-container">
|
||||
<div id="guild-{{guild_data.guild_id}}" class="tab-content {% if not loop.first %}hidden{% endif %}">
|
||||
<div class="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-lg p-3 space-y-2">
|
||||
{% for role in guild_data.roles %}
|
||||
<div class="role-item">
|
||||
<input type="checkbox" name="moderation_staff_role_ids" id="role-{{role.id}}" value="{{role.id}}" {% if role.id|string in selected_roles %}checked{% endif %}>
|
||||
<label for="role-{{role.id}}">
|
||||
<span class="role-color" style="background-color: {% if role.color.value != 0 %}#{{ '%06x' % role.color.value }}{% else %}var(--text-muted){% endif %};"></span>
|
||||
{{role.name}}
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 p-1 rounded">
|
||||
<input type="checkbox" name="moderation_staff_role_ids" value="{{role.id}}"
|
||||
{% if role.id|string in selected_roles %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
{% if role.color.value != 0 %}
|
||||
<span style="color:#{{ '%06x' % role.color.value }}">●</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400">○</span>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{role.name}}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<small>Sélectionnez les rôles qui peuvent utiliser les commandes de modération</small>
|
||||
|
||||
<label for="moderation_embed_delete_delay">Délai de suppression des embeds (secondes)</label>
|
||||
<input name="moderation_embed_delete_delay" id="moderation_embed_delete_delay" type="number" value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" placeholder="0" min="0" />
|
||||
<small>Mettre 0 pour ne jamais supprimer automatiquement</small>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Sélectionnez les rôles qui peuvent utiliser les commandes de modération</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="moderation_embed_delete_delay" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Délai de suppression des embeds (secondes)</label>
|
||||
<input name="moderation_embed_delete_delay" id="moderation_embed_delete_delay" type="number" min="0"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
||||
value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" placeholder="0"/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Mettre 0 pour ne pas supprimer automatiquement</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-actions">
|
||||
<input type="submit" value="💾 Enregistrer Discord">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Enregistrer la configuration Discord
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Section Twitch -->
|
||||
<div class="config-section" data-section="twitch">
|
||||
<div class="config-section-header" onclick="toggleSection(this)">
|
||||
<div class="config-section-icon">📺</div>
|
||||
<div class="config-section-title">
|
||||
<h2>Twitch</h2>
|
||||
<p>Configuration de l'API Twitch pour les alertes live</p>
|
||||
</div>
|
||||
<svg class="config-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<svg class="w-6 h-6 text-purple-500" fill="currentColor" viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57l-.002-5.143zm3.43 0H16.714v5.143H15V4.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0H6zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714v9.429z"/></svg>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">API Twitch</h2>
|
||||
</div>
|
||||
<div class="config-section-content">
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
<label for="twitch_client_id">Client ID</label>
|
||||
<input name="twitch_client_id" id="twitch_client_id" type="text" value="{{ configuration.getValue('twitch_client_id') }}" placeholder="Votre Client ID Twitch" />
|
||||
|
||||
<label for="twitch_client_secret">Client Secret</label>
|
||||
<div class="token-input-wrapper">
|
||||
<input name="twitch_client_secret" id="twitch_client_secret" type="password" value="{{ configuration.getValue('twitch_client_secret') }}" placeholder="Votre Client Secret" />
|
||||
<button type="button" class="token-toggle" onclick="toggleTokenVisibility('twitch_client_secret', this)" title="Afficher/Masquer">👁️</button>
|
||||
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="twitch_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client ID</label>
|
||||
<input name="twitch_client_id" id="twitch_client_id" type="text"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
value="{{ configuration.getValue('twitch_client_id') }}"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="twitch_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Secret</label>
|
||||
<input name="twitch_client_secret" id="twitch_client_secret" type="text"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
value="{{ configuration.getValue('twitch_client_secret') }}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="twitch_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Chaîne à rejoindre</label>
|
||||
<input name="twitch_channel" id="twitch_channel" type="text"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="#machinTruc"
|
||||
value="{{ configuration.getValue('twitch_channel') }}"/>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Fonctionnalités du bot Twitch</h3>
|
||||
|
||||
<label for="twitch_channel">Chaîne à rejoindre</label>
|
||||
<input name="twitch_channel" id="twitch_channel" type="text" value="{{ configuration.getValue('twitch_channel') }}" placeholder="#votre_chaine" />
|
||||
|
||||
<a href="{{ url_for('twitchConfigurationHelp') }}" class="help-link">
|
||||
❓ Besoin d'aide pour configurer Twitch ?
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="twitch_commands_enable" {% if configuration.getValue('twitch_commands_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les commandes personnalisées (!commande)</span>
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 ml-7">Les commandes configurées dans la page "Commandes" seront actives dans le chat Twitch</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Enregistrer la configuration Twitch
|
||||
</button>
|
||||
<a href="{{ url_for('twitchConfigurationHelp') }}" class="text-purple-600 dark:text-purple-400 hover:underline text-sm">Aide</a>
|
||||
</div>
|
||||
|
||||
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<a href="{{ url_for('twitchRequestToken') }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded-lg hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors text-sm font-medium">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path></svg>
|
||||
Obtenir token et refresh token
|
||||
</a>
|
||||
|
||||
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
|
||||
<div class="config-card" style="margin-top: var(--space-lg);">
|
||||
<div class="config-card-header">
|
||||
<h3>🔐 Tokens d'accès</h3>
|
||||
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Access Token</label>
|
||||
<input type="text" readonly
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||
value="{{ configuration.getValue('twitch_access_token') }}"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Refresh Token</label>
|
||||
<input type="text" readonly
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||
value="{{ configuration.getValue('twitch_refresh_token') }}"/>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('twitchRequestToken') }}" class="btn" style="margin-bottom: var(--space-md); display: inline-flex;">
|
||||
🔄 Obtenir/Rafraîchir les tokens
|
||||
</a>
|
||||
|
||||
<label for="twitch_access_token">Access Token</label>
|
||||
<input name="twitch_access_token" id="twitch_access_token" type="text" value="{{ configuration.getValue('twitch_access_token') }}" readonly />
|
||||
|
||||
<label for="twitch_refresh_token">Refresh Token</label>
|
||||
<input name="twitch_refresh_token" id="twitch_refresh_token" type="text" value="{{ configuration.getValue('twitch_refresh_token') }}" readonly />
|
||||
|
||||
<small>⚠️ Nécessite un redémarrage après l'obtention des tokens</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="config-actions">
|
||||
<input type="submit" value="💾 Enregistrer Twitch">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">Nécessite un redémarrage après l'obtention des Tokens.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Section Humble Bundle -->
|
||||
<div class="config-section" data-section="humble">
|
||||
<div class="config-section-header" onclick="toggleSection(this)">
|
||||
<div class="config-section-icon">🎁</div>
|
||||
<div class="config-section-title">
|
||||
<h2>Humble Bundle</h2>
|
||||
<p>Notifications automatiques des nouveaux bundles de jeux</p>
|
||||
</div>
|
||||
<svg class="config-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<svg class="w-6 h-6 text-orange-500" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Humble Bundle</h2>
|
||||
</div>
|
||||
<div class="config-section-content">
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
<p style="color: var(--text-secondary); margin-bottom: var(--space-lg);">
|
||||
Humble Bundle propose régulièrement des bundles de jeux vidéo à prix réduits. Activez les notifications pour recevoir automatiquement les nouveaux packs sur votre serveur Discord.
|
||||
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-4">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Humble Bundle propose régulièrement des bundles de jeux vidéo à des prix réduits. Activez les notifications pour recevoir automatiquement les nouveaux packs disponibles sur votre serveur Discord.
|
||||
</p>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label for="humble_bundle_enable">Activer les notifications Humble Bundle</label>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" name="humble_bundle_enable" id="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="humble_bundle_channel">Canal de notification</label>
|
||||
<select name="humble_bundle_channel" id="humble_bundle_channel">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer les notifications Humble Bundle</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="humble_bundle_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de notification</label>
|
||||
<select name="humble_bundle_channel" id="humble_bundle_channel"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('humble_bundle_channel')==channel.id %}selected{% endif %}>
|
||||
#{{channel.name}}
|
||||
</option>
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('humble_bundle_channel')==channel.id %}selected{% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<div class="config-actions">
|
||||
<input type="submit" value="💾 Enregistrer Humble Bundle">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Zone Danger -->
|
||||
<div class="config-section danger" data-section="danger">
|
||||
<div class="config-section-header" onclick="toggleSection(this)">
|
||||
<div class="config-section-icon">⚠️</div>
|
||||
<div class="config-section-title">
|
||||
<h2>Zone Danger</h2>
|
||||
<p>Actions irréversibles - Utilisez avec précaution</p>
|
||||
</div>
|
||||
<svg class="config-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="config-section-content">
|
||||
<form action="{{ url_for('leaveGuild') }}" method="POST" onsubmit="return confirmLeave();">
|
||||
<div class="danger-box">
|
||||
<span class="danger-box-icon">🚨</span>
|
||||
<div class="danger-box-content">
|
||||
<strong>Quitter un serveur Discord</strong>
|
||||
<p>Cette action est irréversible ! Le bot quittera définitivement le serveur sélectionné. Pour rejoindre à nouveau, vous devrez ré-inviter le bot.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if guilds and guilds|length > 0 %}
|
||||
<label for="guild_id">Sélectionner un serveur à quitter</label>
|
||||
<select name="guild_id" id="guild_id" required>
|
||||
<option value="">-- Choisir un serveur --</option>
|
||||
{% for guild in guilds %}
|
||||
<option value="{{ guild.id }}">{{ guild.name }} ({{ guild.member_count }} membres)</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<div class="toggle-row" style="background: rgba(220, 38, 38, 0.1); margin-top: var(--space-md);">
|
||||
<label for="confirm_leave" style="color: var(--danger);">Je confirme vouloir faire quitter le bot de ce serveur</label>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" name="confirm_leave" id="confirm_leave" value="1" required>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-actions">
|
||||
<input type="submit" value="🚪 Quitter le serveur" class="btn-danger" style="flex: none;">
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color: var(--text-muted); text-align: center; padding: var(--space-lg);">
|
||||
Aucun serveur Discord connecté ou le bot n'est pas encore démarré.
|
||||
</p>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-orange-600 hover:bg-orange-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Enregistrer la configuration Humble Bundle
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Toggle section open/close
|
||||
function toggleSection(header) {
|
||||
const section = header.closest('.config-section');
|
||||
section.classList.toggle('open');
|
||||
}
|
||||
|
||||
// Toggle token visibility
|
||||
function toggleTokenVisibility(inputId, button) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
button.textContent = '🙈';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
button.textContent = '👁️';
|
||||
}
|
||||
}
|
||||
|
||||
// Switch guild tabs
|
||||
function switchGuildTab(button, contentId) {
|
||||
// Get parent tabs container
|
||||
const tabsContainer = button.closest('.guild-tabs');
|
||||
const card = button.closest('.config-card');
|
||||
|
||||
// Deactivate all tabs in this group
|
||||
tabsContainer.querySelectorAll('.guild-tab').forEach(tab => tab.classList.remove('active'));
|
||||
button.classList.add('active');
|
||||
|
||||
// Hide all content in this card
|
||||
card.querySelectorAll('.guild-content').forEach(content => content.classList.remove('active'));
|
||||
document.getElementById(contentId).classList.add('active');
|
||||
}
|
||||
|
||||
// Confirm leave guild
|
||||
function confirmLeave() {
|
||||
const select = document.getElementById('guild_id');
|
||||
if (!select.value) {
|
||||
alert('Veuillez sélectionner un serveur.');
|
||||
return false;
|
||||
}
|
||||
const serverName = select.options[select.selectedIndex].text;
|
||||
return confirm('Êtes-vous VRAIMENT sûr de vouloir quitter le serveur "' + serverName + '" ?\n\nCette action est IRRÉVERSIBLE !');
|
||||
function openTab(evt, tabName) {
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||
document.querySelectorAll('.tab-button').forEach(el => {
|
||||
el.classList.remove('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
|
||||
el.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
|
||||
});
|
||||
document.getElementById(tabName).classList.remove('hidden');
|
||||
evt.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
|
||||
evt.currentTarget.classList.add('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
|
||||
}
|
||||
document.getElementById("defaultOpen")?.click();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>🎮 Jeux Gratuits</h1>
|
||||
<p>Gérez les notifications de jeux gratuits via le flux RSS LootScraper. Activez le module, choisissez les sources à suivre et décidez quels jeux partager avec votre communauté.</p>
|
||||
|
||||
<h2>Configuration</h2>
|
||||
<form action="{{ url_for('updateFreeGamesConfig') }}" method="POST">
|
||||
<fieldset>
|
||||
<legend>Paramètres généraux</legend>
|
||||
|
||||
<label for="freegames_enable">
|
||||
<input type="checkbox" name="freegames_enable" id="freegames_enable" {% if configuration.getValue('freegames_enable') %}checked="checked"{% endif %}>
|
||||
Activer le module Jeux Gratuits
|
||||
</label>
|
||||
|
||||
<label for="freegames_auto_notify">
|
||||
<input type="checkbox" name="freegames_auto_notify" id="freegames_auto_notify" {% if configuration.getValue('freegames_auto_notify') %}checked="checked"{% endif %}>
|
||||
Notification automatique des nouveaux jeux
|
||||
</label>
|
||||
<small>Si activé, les nouveaux jeux des sources sélectionnées seront automatiquement partagés</small>
|
||||
|
||||
<label for="freegames_channel">Canal de notification</label>
|
||||
<select name="freegames_channel" id="freegames_channel">
|
||||
<option value="">-- Sélectionner un canal --</option>
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('freegames_channel')==channel.id %}selected="selected"{% endif %}>
|
||||
{{channel.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Mentions</legend>
|
||||
|
||||
<label for="freegames_mention_type">Type de mention</label>
|
||||
<select name="freegames_mention_type" id="freegames_mention_type" onchange="toggleRoleSelect()">
|
||||
<option value="none" {% if configuration.getValue('freegames_mention_type') == 'none' or not configuration.getValue('freegames_mention_type') %}selected{% endif %}>Aucune mention</option>
|
||||
<option value="here" {% if configuration.getValue('freegames_mention_type') == 'here' %}selected{% endif %}>@here</option>
|
||||
<option value="everyone" {% if configuration.getValue('freegames_mention_type') == 'everyone' %}selected{% endif %}>@everyone</option>
|
||||
<option value="role" {% if configuration.getValue('freegames_mention_type') == 'role' %}selected{% endif %}>Rôle spécifique</option>
|
||||
</select>
|
||||
|
||||
<div id="role-select-container" style="{% if configuration.getValue('freegames_mention_type') != 'role' %}display: none;{% endif %}">
|
||||
<label for="freegames_mention_role">Rôle à mentionner</label>
|
||||
{% for guild_data in roles %}
|
||||
<select name="freegames_mention_role" id="freegames_mention_role">
|
||||
<option value="">-- Sélectionner un rôle --</option>
|
||||
{% for role in guild_data.roles %}
|
||||
<option value="{{role.id}}" {% if configuration.getIntValue('freegames_mention_role')==role.id %}selected="selected"{% endif %}>
|
||||
{% if role.color.value != 0 %}●{% else %}○{% endif %} {{role.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Sources à suivre</legend>
|
||||
<small>Sélectionnez les plateformes dont vous souhaitez recevoir les offres. Si aucune n'est sélectionnée, toutes les sources seront incluses.</small>
|
||||
|
||||
{% set enabled_sources = (configuration.getValue('freegames_sources') or '').split(',') %}
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; margin-top: 15px;">
|
||||
{% for key, name in sources.items() %}
|
||||
<label style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" name="freegames_sources" value="{{key}}" {% if key in enabled_sources or not configuration.getValue('freegames_sources') %}checked{% endif %}>
|
||||
{{name}}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<input type="submit" value="💾 Enregistrer la configuration">
|
||||
</form>
|
||||
|
||||
<h2>Jeux disponibles <span class="badge">{{pending_count}} en attente</span></h2>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<button type="button" onclick="refreshGames()" class="btn-secondary">🔄 Rafraîchir le flux RSS</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label for="filter-source">Filtrer par source :</label>
|
||||
<select id="filter-source" onchange="filterGames()">
|
||||
<option value="all">Toutes les sources</option>
|
||||
{% for key, name in sources.items() %}
|
||||
<option value="{{name}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="filter-status" style="margin-left: 20px;">Filtrer par statut :</label>
|
||||
<select id="filter-status" onchange="filterGames()">
|
||||
<option value="all">Tous</option>
|
||||
<option value="pending">En attente</option>
|
||||
<option value="notified">Notifiés</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="games-list">
|
||||
{% for game in games %}
|
||||
<div class="game-card" data-source="{{game.source}}" data-status="{% if game.notified %}notified{% else %}pending{% endif %}">
|
||||
<div class="game-header">
|
||||
{% if game.image_url %}
|
||||
<img src="{{game.image_url}}" alt="{{game.title}}" class="game-thumb">
|
||||
{% else %}
|
||||
<div class="game-thumb-placeholder">🎮</div>
|
||||
{% endif %}
|
||||
<div class="game-info">
|
||||
<h3>{{game.title}}</h3>
|
||||
<span class="source-badge">{{game.source}}</span>
|
||||
{% if game.notified %}
|
||||
<span class="status-badge notified">✓ Notifié</span>
|
||||
{% else %}
|
||||
<span class="status-badge pending">⏳ En attente</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if game.description %}
|
||||
<p class="game-description">{{game.description[:150]}}{% if game.description|length > 150 %}...{% endif %}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="game-meta">
|
||||
{% if game.valid_to %}
|
||||
<span title="Date de fin">⏰ Jusqu'au {{game.valid_to.strftime('%d/%m/%Y')}}</span>
|
||||
{% endif %}
|
||||
<a href="{{game.url}}" target="_blank" rel="noopener">🔗 Voir l'offre</a>
|
||||
</div>
|
||||
|
||||
<div class="game-actions">
|
||||
{% if not game.notified %}
|
||||
<button type="button" onclick="notifyGame({{game.id}}, this)" class="btn-primary">📢 Notifier</button>
|
||||
<button type="button" onclick="markNotified({{game.id}}, this)" class="btn-secondary">✓ Marquer comme notifié</button>
|
||||
{% else %}
|
||||
<button type="button" onclick="resetGame({{game.id}}, this)" class="btn-secondary">↩️ Réinitialiser</button>
|
||||
{% endif %}
|
||||
<button type="button" onclick="deleteGame({{game.id}}, this)" class="btn-danger">🗑️ Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>Aucun jeu gratuit trouvé. Cliquez sur "Rafraîchir le flux RSS" pour récupérer les dernières offres.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
background: #5865F2;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.games-list {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
background: var(--color-bg-secondary, #f5f5f5);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
}
|
||||
|
||||
.game-card[data-status="notified"] {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.game-header {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.game-thumb {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.game-thumb-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--color-bg-tertiary, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.game-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.game-info h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background: #FF9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.notified {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.game-description {
|
||||
color: var(--color-text-secondary, #666);
|
||||
font-size: 0.9em;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.game-meta {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-secondary, #666);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.game-meta a {
|
||||
color: #5865F2;
|
||||
}
|
||||
|
||||
.game-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.game-actions button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.game-actions button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #5865F2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #747F8D;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ED4245;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function toggleRoleSelect() {
|
||||
const mentionType = document.getElementById('freegames_mention_type').value;
|
||||
const roleContainer = document.getElementById('role-select-container');
|
||||
roleContainer.style.display = mentionType === 'role' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function filterGames() {
|
||||
const sourceFilter = document.getElementById('filter-source').value;
|
||||
const statusFilter = document.getElementById('filter-status').value;
|
||||
const cards = document.querySelectorAll('.game-card');
|
||||
|
||||
cards.forEach(card => {
|
||||
const source = card.dataset.source;
|
||||
const status = card.dataset.status;
|
||||
|
||||
const sourceMatch = sourceFilter === 'all' || source === sourceFilter;
|
||||
const statusMatch = statusFilter === 'all' || status === statusFilter;
|
||||
|
||||
card.classList.toggle('hidden', !(sourceMatch && statusMatch));
|
||||
});
|
||||
}
|
||||
|
||||
function refreshGames() {
|
||||
fetch('/freegames/refresh', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
alert(data.message);
|
||||
if (data.new_games > 0) {
|
||||
location.reload();
|
||||
}
|
||||
})
|
||||
.catch(e => alert('Erreur: ' + e));
|
||||
}
|
||||
|
||||
function notifyGame(id, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Envoi...';
|
||||
|
||||
fetch('/freegames/notify/' + id, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Erreur: ' + data.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📢 Notifier';
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
alert('Erreur: ' + e);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📢 Notifier';
|
||||
});
|
||||
}
|
||||
|
||||
function markNotified(id, btn) {
|
||||
fetch('/freegames/mark-notified/' + id, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
})
|
||||
.catch(e => alert('Erreur: ' + e));
|
||||
}
|
||||
|
||||
function resetGame(id, btn) {
|
||||
fetch('/freegames/reset/' + id, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
})
|
||||
.catch(e => alert('Erreur: ' + e));
|
||||
}
|
||||
|
||||
function deleteGame(id, btn) {
|
||||
if (!confirm('Supprimer ce jeu de la liste ?')) return;
|
||||
|
||||
fetch('/freegames/delete/' + id, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
btn.closest('.game-card').remove();
|
||||
}
|
||||
})
|
||||
.catch(e => alert('Erreur: ' + e));
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">FreeLoot — Jeux gratuits</h1>
|
||||
{% if request.args.get('msg') %}
|
||||
{% set msg_type = request.args.get('type') %}
|
||||
<div class="mb-4 p-4 rounded-lg {% if msg_type == 'success' %}bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300{% elif msg_type == 'error' %}bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300{% else %}bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300{% endif %}">
|
||||
{{ request.args.get('msg') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Notifications des jeux gratuits (Epic Games, Amazon Prime, GOG, Google Play, Apple App Store) via le flux
|
||||
<a href="https://feed.eikowagenknecht.com/lootscraper.xml" target="_blank" rel="noopener" class="text-amber-700 dark:text-amber-300 hover:underline">LootScraper</a>.
|
||||
Choisissez le canal Discord et les types de loot à notifier (PC, Android, iOS selon la source). Le bot vérifie le flux environ toutes les 30 minutes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<span class="text-3xl">🎁</span>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Configuration FreeLoot</h2>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('updateFreeLoot') }}" method="POST" class="space-y-6">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="freeloot_enable" {% if configuration.getValue('freeloot_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer les notifications FreeLoot</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="freeloot_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal Discord pour les notifications</label>
|
||||
<select name="freeloot_channel_id" id="freeloot_channel_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-amber-500 focus:border-transparent transition-all">
|
||||
<option value="">— Choisir un canal —</option>
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.id }}" {% if configuration.getIntValue('freeloot_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Mentions (optionnel)</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Choisissez qui mentionner au début du message (avant l’embed).</p>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="freeloot_mention_everyone" {% if mention_everyone %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">@everyone</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="freeloot_mention_here" {% if mention_here %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">@here</span>
|
||||
</label>
|
||||
</div>
|
||||
{% if roles %}
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôles à mentionner</p>
|
||||
{% if roles|length > 1 %}
|
||||
<div class="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-600 mb-3">
|
||||
{% for guild_data in roles %}
|
||||
<button type="button" class="freeloot-role-tab px-4 py-2 text-sm font-medium rounded-t-lg transition-colors {% if loop.first %}bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}"
|
||||
data-tab="freeloot-roles-{{ guild_data.guild_id }}" {% if loop.first %}data-default{% endif %}>
|
||||
{{ guild_data.guild_name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for guild_data in roles %}
|
||||
<div id="freeloot-roles-{{ guild_data.guild_id }}" class="freeloot-role-panel {% if not loop.first %}hidden{% endif %} max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-lg p-3 space-y-2">
|
||||
{% for role in guild_data.roles %}
|
||||
<label class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600/50 p-1 rounded">
|
||||
<input type="checkbox" name="freeloot_mention_roles" value="{{ role.id }}"
|
||||
{% if role.id|string in mention_role_ids %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
|
||||
{% if role.color is defined and role.color is not none and role.color.value != 0 %}
|
||||
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color:#{{ '%06x'|format(role.color.value) }}"></span>
|
||||
{% else %}
|
||||
<span class="w-3 h-3 rounded-full flex-shrink-0 bg-gray-400"></span>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ role.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Types de loot à notifier</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Cochez les sources et plateformes pour lesquelles vous voulez recevoir une notification.</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{% for key, label, emoji in sources %}
|
||||
<label class="flex items-center gap-2 cursor-pointer p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600/50 transition-colors">
|
||||
<input type="checkbox" name="freeloot_sources" value="{{ key }}"
|
||||
{% if not enabled_sources or key in enabled_sources %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
|
||||
<span class="text-lg" title="{{ label }}">{{ emoji }}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Enregistrer
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-3">Aperçu de l’embed Discord (style DraftBot)</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Exemple du message envoyé dans le canal.</p>
|
||||
<div class="inline-block rounded-r-lg overflow-hidden border border-gray-300 dark:border-gray-600 bg-[#2f3136] max-w-lg shadow-lg" style="border-left: 4px solid #E67E22;">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-2 mb-2">
|
||||
<a href="#" class="text-[#00a8fc] hover:underline font-semibold text-base flex-1">Definitely Not Fried Chicken gratuit sur l'Epic Games Store !</a>
|
||||
<img src="https://store.epicgames.com/favicon.ico" alt="" class="w-10 h-10 rounded flex-shrink-0" title="Logo boutique">
|
||||
</div>
|
||||
<p class="text-[#dcddde] text-sm leading-relaxed mb-3">Definitely Not Fried Chicken is a business management sim with a twist! Grow your drugs trade through legitimate fronts, managing both sides of the business. Acquire new "businesses", meet new clientele, develop more potent narcotics…</p>
|
||||
<div class="text-sm mb-2">
|
||||
<span class="text-[#b9bbbe]">Prix</span>
|
||||
<p class="text-[#dcddde]"><strong>Gratuit</strong> • jusqu'au 05/02/2026</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm mb-2">
|
||||
<div><span class="text-[#b9bbbe]">Prix recommandé</span><p class="text-[#dcddde]">39.99 EUR</p></div>
|
||||
<div><span class="text-[#b9bbbe]">Genres</span><p class="text-[#dcddde]">Simulation, Indie</p></div>
|
||||
<div><span class="text-[#b9bbbe]">Ratings</span><p class="text-[#dcddde]">PEGI 18, USK 18</p></div>
|
||||
</div>
|
||||
<p class="mb-3"><a href="#" class="text-[#00a8fc] hover:underline text-sm">Ouvrir dans la boutique !</a></p>
|
||||
<div class="rounded overflow-hidden bg-[#202225] aspect-video flex items-center justify-center my-2">
|
||||
<span class="text-4xl text-gray-500">🎁</span>
|
||||
</div>
|
||||
<p class="text-xs text-[#72767d] pt-1">MamieHenriette • FreeLoot</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.freeloot-role-tab').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
var tabId = this.getAttribute('data-tab');
|
||||
document.querySelectorAll('.freeloot-role-panel').forEach(p => p.classList.add('hidden'));
|
||||
document.querySelectorAll('.freeloot-role-tab').forEach(b => {
|
||||
b.classList.remove('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
|
||||
b.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
|
||||
});
|
||||
document.getElementById(tabId).classList.remove('hidden');
|
||||
this.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
|
||||
this.classList.add('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
|
||||
});
|
||||
});
|
||||
document.querySelector('.freeloot-role-tab[data-default]')?.click();
|
||||
</script>
|
||||
|
||||
{% if entries %}
|
||||
<div class="mt-8">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Jeux gratuits actuellement disponibles</h2>
|
||||
</div>
|
||||
<div class="p-6 overflow-x-auto">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for e in entries %}
|
||||
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md transition-shadow flex flex-col">
|
||||
<div class="aspect-video bg-gray-100 dark:bg-gray-700 flex items-center justify-center overflow-hidden">
|
||||
{% if e.image_url %}
|
||||
<img src="{{ e.image_url }}" alt="" class="w-full h-full object-cover" loading="lazy" />
|
||||
{% else %}
|
||||
<span class="text-4xl text-gray-400 dark:text-gray-500">🎁</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="p-4 flex flex-col flex-1 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-1 line-clamp-2" title="{{ e.game_name }}">{{ e.game_name }}</h3>
|
||||
<p class="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
<span>{{ e.emoji }}</span>
|
||||
<span>{{ e.source_label }}</span>
|
||||
</p>
|
||||
{% if e.recommended_price or e.genres or e.rating %}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-0.5 mb-2">
|
||||
{% if e.recommended_price %}<p><span class="text-gray-500 dark:text-gray-500">Prix recommandé:</span> {{ e.recommended_price }}</p>{% endif %}
|
||||
{% if e.genres %}<p><span class="text-gray-500 dark:text-gray-500">Genres:</span> {{ e.genres }}</p>{% endif %}
|
||||
{% if e.rating %}<p><span class="text-gray-500 dark:text-gray-500">Ratings:</span> {{ e.rating }}</p>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if e.updated_formatted %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-3">{{ e.updated_formatted }}</p>
|
||||
{% endif %}
|
||||
<div class="mt-auto flex flex-col gap-2">
|
||||
<form action="{{ url_for('send_free_loot_to_discord') }}" method="POST" class="w-full">
|
||||
<input type="hidden" name="entry_id" value="{{ e.id }}">
|
||||
<button type="submit" class="w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>
|
||||
Envoyer sur Discord
|
||||
</button>
|
||||
</form>
|
||||
{% if e.link %}
|
||||
<a href="{{ e.link }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center gap-2 w-full px-3 py-2 text-sm font-medium rounded-lg bg-amber-600 hover:bg-amber-700 text-white transition-colors focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Ouvrir dans la boutique
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mt-8 p-4 rounded-lg bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Le flux LootScraper n’a pas pu être chargé. Réessayez plus tard.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,41 +1,69 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Humeurs de Mamie</h1>
|
||||
<p>Définissez les statuts Discord qui changeront automatiquement toutes les 10 minutes pour donner de la personnalité à votre bot.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<h3>📝 Variables disponibles</h3>
|
||||
<p>Vous pouvez utiliser ces variables dans vos humeurs, elles seront remplacées automatiquement :</p>
|
||||
<ul>
|
||||
<li><code>{servers}</code> — Nombre de serveurs gérés par le bot</li>
|
||||
<li><code>{members}</code> — Nombre total de membres sur tous les serveurs</li>
|
||||
<li><code>{channels}</code> — Nombre total de salons sur tous les serveurs</li>
|
||||
</ul>
|
||||
<p><em>Exemple : "Je surveille {members} personnes sur {servers} serveurs 👀"</em></p>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Humeurs de Mamie</h1>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Définissez les statuts Discord qui changeront automatiquement toutes les 10 minutes pour donner de la personnalité à votre bot.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Texte</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for humeur in humeurs %}
|
||||
<tr>
|
||||
<td>{{humeur.text}}</td>
|
||||
<td><a href="{{ url_for('delHumeur', id = humeur.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette humeur ?')">Supprimer</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Liste des humeurs</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Texte</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-24">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for humeur in humeurs %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td class="px-6 py-4 text-gray-700 dark:text-gray-300">{{ humeur.text }}</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<a href="{{ url_for('delHumeur', id = humeur.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette humeur ?')"
|
||||
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400 inline-block"
|
||||
title="Supprimer">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="2" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
Aucune humeur configurée. Ajoutez-en une ci-dessous.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Ajouter une humeur</h2>
|
||||
<form action="{{ url_for('addHumeur') }}" method="POST">
|
||||
<label for="text">Texte</label>
|
||||
<input name="text" type="text" />
|
||||
<input type="Submit" value="Ajouter">
|
||||
</form>
|
||||
{% endblock %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Ajouter une humeur</h2>
|
||||
|
||||
<form action="{{ url_for('addHumeur') }}" method="POST" class="space-y-6">
|
||||
<div>
|
||||
<label for="text" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Texte du statut</label>
|
||||
<input name="text" id="text" type="text" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="Joue à un jeu vidéo..."/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Ajouter l'humeur
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
+86
-145
@@ -1,161 +1,102 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Tableau de bord</h1>
|
||||
<p>Bienvenue sur l'interface d'administration de Mamie Henriette.</p>
|
||||
<div class="text-center py-10">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-slate-800 dark:text-white mb-3">
|
||||
Panneau d'administration
|
||||
</h1>
|
||||
<p class="text-base text-slate-600 dark:text-slate-400 max-w-xl mx-auto">
|
||||
Gérez les fonctionnalités de votre bot Discord et Twitch depuis cette interface.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<section class="dashboard-section">
|
||||
<h2>🔌 Services</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card {{ 'online' if stats.discord_connected else 'offline' }}">
|
||||
<div class="stat-icon">🤖</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Discord</span>
|
||||
<span class="stat-value">{{ 'Connecté' if stats.discord_connected else 'Déconnecté' }}</span>
|
||||
{% if stats.discord_connected and stats.discord_bot_name %}
|
||||
<span class="stat-sub">{{ stats.discord_bot_name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stat-status {{ 'online' if stats.discord_connected else 'offline' }}"></div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card {{ 'online' if stats.twitch_connected else 'offline' }}">
|
||||
<div class="stat-icon">📺</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Twitch</span>
|
||||
<span class="stat-value">{{ 'Connecté' if stats.twitch_connected else 'Déconnecté' }}</span>
|
||||
{% if stats.twitch_connected and stats.twitch_channel %}
|
||||
<span class="stat-sub">#{{ stats.twitch_channel }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stat-status {{ 'online' if stats.twitch_connected else 'offline' }}"></div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden mb-8">
|
||||
<div class="p-4 sm:p-6 border-b border-slate-200 dark:border-slate-700 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center justify-center w-3 h-3 rounded-full {% if discord_connected %}bg-emerald-500 ring-4 ring-emerald-500/30{% else %}bg-slate-400 ring-4 ring-slate-400/30{% endif %}" title="{% if discord_connected %}Bot Discord connecté{% else %}Bot Discord déconnecté{% endif %}"></span>
|
||||
<h2 class="text-xl font-semibold text-slate-800 dark:text-white">Discord</h2>
|
||||
</div>
|
||||
<span class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{% if discord_connected %}Connecté{% else %}Déconnecté{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Statistiques Discord -->
|
||||
{% if stats.discord_connected %}
|
||||
<section class="dashboard-section">
|
||||
<h2>📊 Statistiques Discord</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🏠</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Serveurs</span>
|
||||
<span class="stat-value big">{{ stats.discord_guilds }}</span>
|
||||
</div>
|
||||
<div class="p-4 sm:p-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Serveurs connectés</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ discord_guild_count }}</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">👥</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Membres</span>
|
||||
<span class="stat-value big">{{ stats.discord_members }}</span>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Sanctions enregistrées</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ sanctions_count }}</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">💬</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Salons</span>
|
||||
<span class="stat-value big">{{ stats.discord_channels }}</span>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600 sm:col-span-2 lg:col-span-2 flex items-center justify-center">
|
||||
<div class="flex flex-wrap gap-3 justify-center">
|
||||
<a href="/live-alert" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Alertes Live</a>
|
||||
<a href="/youtube" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Notification YouTube</a>
|
||||
<a href="/humeurs" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Humeurs</a>
|
||||
<a href="/protondb" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">ProtonDB</a>
|
||||
<a href="/commandes" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Commandes</a>
|
||||
<a href="/moderation" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Modération</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Envoyer une annonce -->
|
||||
<section class="dashboard-section">
|
||||
<h2>📢 Envoyer une annonce</h2>
|
||||
<form id="announce-form" class="announce-form-inline">
|
||||
<div class="announce-row">
|
||||
<input type="text" id="message-input" name="message" placeholder="Votre message..." required>
|
||||
<select id="channel-select" name="channel_id" required>
|
||||
<option value="">Salon</option>
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.id }}">#{{ channel.name }} ({{ channel.guild_name }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" id="send-btn">
|
||||
<span class="btn-text">📨 Envoyer</span>
|
||||
<span class="btn-loading" style="display: none;">⏳</span>
|
||||
</button>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden mb-8">
|
||||
<div class="p-4 sm:p-6 border-b border-slate-200 dark:border-slate-700 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center justify-center w-3 h-3 rounded-full {% if twitch_connected %}bg-emerald-500 ring-4 ring-emerald-500/30{% else %}bg-slate-400 ring-4 ring-slate-400/30{% endif %}" title="{% if twitch_connected %}Bot Twitch connecté{% else %}Bot Twitch déconnecté{% endif %}"></span>
|
||||
<h2 class="text-xl font-semibold text-slate-800 dark:text-white">Twitch</h2>
|
||||
</div>
|
||||
<div id="announce-result" class="announce-result" style="display: none;"></div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- Fonctionnalités -->
|
||||
<section class="dashboard-section">
|
||||
<h2>⚡ Fonctionnalités</h2>
|
||||
<div class="cogs-grid">
|
||||
{% for cog_name, enabled in stats.cogs_enabled.items() %}
|
||||
<div class="cog-item {{ 'enabled' if enabled else 'disabled' }}">
|
||||
<span class="cog-status">{{ '✅' if enabled else '❌' }}</span>
|
||||
<span class="cog-name">{{ cog_name }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Aucune information disponible. Le bot Discord n'est peut-être pas encore connecté.</p>
|
||||
{% endfor %}
|
||||
<span class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{% if twitch_connected %}Connecté{% else %}Déconnecté{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="p-4 sm:p-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Canal connecté</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{% if twitch_channel_name %}{{ twitch_channel_name }}{% else %}—{% endif %}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Annonces configurées</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ twitch_announcements_count }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Actions de modération</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ twitch_moderation_count }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600 flex items-center justify-center">
|
||||
<div class="flex flex-wrap gap-3 justify-center">
|
||||
<a href="/announcements" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-medium hover:bg-purple-700 transition-colors">Annonces</a>
|
||||
<a href="/twitch-moderation" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Modération</a>
|
||||
<a href="/link-filter" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Filtre de liens</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if stats.last_update %}
|
||||
<p class="last-update">Dernière mise à jour : {{ stats.last_update.strftime('%d/%m/%Y à %H:%M:%S') }}</p>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
document.getElementById('announce-form')?.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const btn = document.getElementById('send-btn');
|
||||
const btnText = btn.querySelector('.btn-text');
|
||||
const btnLoading = btn.querySelector('.btn-loading');
|
||||
const result = document.getElementById('announce-result');
|
||||
|
||||
const channelId = document.getElementById('channel-select').value;
|
||||
const message = document.getElementById('message-input').value;
|
||||
|
||||
// UI loading
|
||||
btn.disabled = true;
|
||||
btnText.style.display = 'none';
|
||||
btnLoading.style.display = 'inline';
|
||||
result.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/send-message', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel_id: channelId,
|
||||
message: message
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
result.style.display = 'block';
|
||||
if (data.success) {
|
||||
result.className = 'announce-result success';
|
||||
result.textContent = '✅ ' + data.message;
|
||||
document.getElementById('message-input').value = '';
|
||||
} else {
|
||||
result.className = 'announce-result error';
|
||||
result.textContent = '❌ ' + data.error;
|
||||
}
|
||||
} catch (error) {
|
||||
result.style.display = 'block';
|
||||
result.className = 'announce-result error';
|
||||
result.textContent = '❌ Erreur de connexion';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btnText.style.display = 'inline';
|
||||
btnLoading.style.display = 'none';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 border border-slate-200 dark:border-slate-700">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-800 dark:text-white mb-2">À propos</h3>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400 mb-4">
|
||||
Mamie Henriette est un bot open source pour Discord et Twitch, développé par la communauté.
|
||||
Cette interface vous permet de configurer et gérer toutes les fonctionnalités.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="https://github.com/skylanix/MamieHenriette" target="_blank" class="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-800 dark:bg-slate-700 text-white rounded text-sm hover:bg-slate-700 dark:hover:bg-slate-600 transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<a href="https://discord.com/invite/UwAPqMJnx3" target="_blank" class="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-600 rounded text-sm hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"></path></svg>
|
||||
Discord
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Filtre de liens</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">Bloquez les liens non autorises sur votre chat Twitch.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-3 rounded-lg {{ 'bg-green-100 dark:bg-green-900/30' if config.enabled else 'bg-gray-100 dark:bg-gray-700' }}">
|
||||
<svg class="w-6 h-6 {{ 'text-green-600 dark:text-green-400' if config.enabled else 'text-gray-500' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Protection des liens</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'Active' if config.enabled else 'Desactive' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('toggle_link_filter') }}" class="px-4 py-2 rounded-lg font-medium transition-colors {{ 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' if config.enabled else 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' }}">
|
||||
{{ 'Desactiver' if config.enabled else 'Activer' }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('update_link_filter') }}" method="POST" class="space-y-6">
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">Autoriser les liens pour</h3>
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input type="checkbox" name="allow_moderators" {{ 'checked' if config.allow_moderators }} class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
|
||||
<div>
|
||||
<span class="text-gray-900 dark:text-white font-medium">Moderateurs</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Les modos peuvent toujours poster des liens</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input type="checkbox" name="allow_vips" {{ 'checked' if config.allow_vips }} class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
|
||||
<div>
|
||||
<span class="text-gray-900 dark:text-white font-medium">VIP</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Les VIP peuvent poster des liens</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input type="checkbox" name="allow_subscribers" {{ 'checked' if config.allow_subscribers }} class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
|
||||
<div>
|
||||
<span class="text-gray-900 dark:text-white font-medium">Abonnes</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Les abonnes peuvent poster des liens</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Duree du timeout (secondes)</label>
|
||||
<input type="number" name="timeout_duration" value="{{ config.timeout_duration }}" min="0" max="1209600"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">0 = pas de timeout, juste suppression du message</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message d'avertissement</label>
|
||||
<textarea name="warning_message" rows="2"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none">{{ config.warning_message }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium">
|
||||
Enregistrer les parametres
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
|
||||
</svg>
|
||||
Domaines autorises ({{ domains|length }})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('add_allowed_domain') }}" method="POST" class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex gap-2">
|
||||
<input type="text" name="domain" placeholder="exemple.com" required
|
||||
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm">
|
||||
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors text-sm font-medium">
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if domains %}
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for domain in domains %}
|
||||
<div class="px-4 py-2 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<code class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ domain.domain }}</code>
|
||||
<a href="{{ url_for('delete_allowed_domain', domain_id=domain.id) }}" class="text-red-600 hover:text-red-700 text-sm">Supprimer</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||
Aucun domaine autorise
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||||
</svg>
|
||||
Viewers autorises ({{ users|length }})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('add_allowed_user') }}" method="POST" class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex gap-2">
|
||||
<input type="text" name="username" placeholder="@pseudo" required
|
||||
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm">
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium">
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if users %}
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for user in users %}
|
||||
<div class="px-4 py-2 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">@{{ user.username }}</span>
|
||||
<a href="{{ url_for('delete_allowed_user', user_id=user.id) }}" class="text-red-600 hover:text-red-700 text-sm">Supprimer</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||
Aucun viewer en liste blanche
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 rounded-xl p-6 border border-blue-200 dark:border-blue-800">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Commande Permit
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4">
|
||||
Utilisez la commande <code class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs">!permit</code> pour autoriser temporairement un viewer a poster un lien.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="px-2 py-1 bg-white dark:bg-gray-800 rounded text-xs font-mono text-purple-600 dark:text-purple-400">!permit @viewer</code>
|
||||
<span class="text-gray-600 dark:text-gray-400">Autorise 1 min</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="px-2 py-1 bg-white dark:bg-gray-800 rounded text-xs font-mono text-purple-600 dark:text-purple-400">!permit @viewer 5</code>
|
||||
<span class="text-gray-600 dark:text-gray-400">Autorise 5 min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,76 +1,335 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Alerte Live</h1>
|
||||
|
||||
<p>
|
||||
Liste des chaines surveillées pour les alertes de live twitch.
|
||||
|
||||
Le bot vérifie toutes les 5 minutes qui est en live dans la liste en dessous.
|
||||
Le bot enregistre le status de stream toutes les 5 minutes, quand le status pass de "hors-ligne" à "en ligne" alors
|
||||
le bot le notifiera sur discord.
|
||||
Ne peu surveiller qu'au maximum 100 chaines.
|
||||
</p>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Alerte Live</h1>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Liste des chaînes surveillées pour les alertes de live Twitch.
|
||||
Le bot vérifie toutes les 5 minutes qui est en live dans la liste en dessous.
|
||||
Le bot enregistre le status de stream toutes les 5 minutes, quand le status passe de "hors-ligne" à "en ligne" alors
|
||||
le bot enverra une notification (embed Discord) sur le canal choisi.
|
||||
<span class="font-medium text-blue-600 dark:text-blue-400">Ne peut surveiller qu'au maximum 100 chaînes.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not alert %}
|
||||
<h2>Alertes</h2>
|
||||
<table class="live-alert">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chaine</th>
|
||||
<th>Canal</th>
|
||||
<th>Message</th>
|
||||
<th>#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for alert in alerts %}
|
||||
<tr>
|
||||
<td>{{alert.login}}</td>
|
||||
<td>{{alert.notify_channel_name}}</td>
|
||||
<td>{{alert.message}}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('toggleLiveAlert', id = alert.id) }}" class="icon">{{ '✅' if alert.enable else '❌' }}</a>
|
||||
<a href="{{ url_for('openEditLiveAlert', id = alert.id) }}" class="icon">✐</a>
|
||||
<a href="{{ url_for('delLiveAlert', id = alert.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette alerte ?')" class="icon">🗑</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Alertes configurées</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Chaîne</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Canal</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message / Embed</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" title="Le bot affichera 'Regarde [streamer]' comme activité">Activité</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for alert in alerts %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<a href="https://www.twitch.tv/{{alert.login}}" target="_blank" class="text-purple-600 dark:text-purple-400 hover:underline font-medium">{{alert.login}}</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-700 dark:text-gray-300">{{alert.notify_channel_name}}</td>
|
||||
<td class="px-6 py-4 text-gray-600 dark:text-gray-400 max-w-md truncate">{{alert.message or '(embed)'}}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<a href="{{ url_for('toggleWatchActivity', id = alert.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
title="{{ 'Désactiver l\'activité' if alert.watch_activity else 'Activer l\'activité' }}">
|
||||
{{ '👁️' if alert.watch_activity else '👁️🗨️' }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<a href="{{ url_for('toggleLiveAlert', id = alert.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
title="{{ 'Désactiver' if alert.enable else 'Activer' }}">
|
||||
{{ '✅' if alert.enable else '❌' }}
|
||||
</a>
|
||||
<a href="{{ url_for('openEditLiveAlert', id = alert.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors text-blue-600 dark:text-blue-400"
|
||||
title="Modifier">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||
</a>
|
||||
<a href="{{ url_for('delLiveAlert', id = alert.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette alerte ?')"
|
||||
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400"
|
||||
title="Supprimer">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
Aucune alerte configurée. Ajoutez-en une ci-dessous.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>{{ 'Editer une alerte' if alert else 'Ajouter une alerte de Live' }}</h2>
|
||||
<form action="{{ url_for('submitEditLiveAlert', id = alert.id) if alert else url_for('addLiveAlert') }}" method="POST">
|
||||
<label for="login">Chaine</label>
|
||||
<input name="login" type="text" maxlength="32" required="required" value="{{alert.login if alert}}"/>
|
||||
<label for="notify_channel">Canal de Notification</label>
|
||||
<select name="notify_channel">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}"{% if alert and alert.notify_channel == channel.id %}
|
||||
selected="selected" {% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label for="message">Message</label>
|
||||
<textarea name="message" rows="5" cols="50" required="required">{{alert.message if alert}}</textarea>
|
||||
<input type="Submit" value="Ajouter">
|
||||
<p>
|
||||
La chaine est le login de la chaine, par exemple <strong>chainesteve</strong> pour <strong>https://www.twitch.tv/chainesteve</strong>.
|
||||
</p>
|
||||
<p>
|
||||
Pour le message vous avez acces à ces variables :
|
||||
<ul>
|
||||
<li>{0.user_login} : pour le lien vers la chaine</li>
|
||||
<li>{0.user_name} : à priviligier pour le text</li>
|
||||
<li>{0.game_name}</li>
|
||||
<li>{0.title}</li>
|
||||
<li>{0.language}</li>
|
||||
</ul>
|
||||
Le message est au format <a href="https://commonmark.org/" target="_blank">common-mark</a> dans la limite de ce que
|
||||
support discord.
|
||||
Pour mettre un lien vers la chaine : [description](https://www.twitch.tv/{0.user_login})
|
||||
</p>
|
||||
</form>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
{{ 'Modifier l\'alerte' if alert else 'Ajouter une alerte de Live' }}
|
||||
</h2>
|
||||
|
||||
{% endblock %}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<form id="live-alert-form" action="{{ url_for('submitEditLiveAlert', id = alert.id) if alert else url_for('addLiveAlert') }}" method="POST" class="space-y-6">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Configuration de base</h3>
|
||||
|
||||
<div>
|
||||
<label for="login" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Chaîne Twitch</label>
|
||||
<input name="login" id="login" type="text" maxlength="32" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="chainesteve"
|
||||
value="{{alert.login if alert}}"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="notify_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de notification Discord</label>
|
||||
<select name="notify_channel" id="notify_channel"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if alert and alert.notify_channel == channel.id %}selected{% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message (optionnel, avant l'embed)</label>
|
||||
<textarea name="message" id="message" rows="2"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all resize-y"
|
||||
placeholder="Message envoyé avant l'embed">{{alert.message if alert}}</textarea>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Variables: {user_name}, {title}, [lien](https://www.twitch.tv/{user_login})</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" name="watch_activity" id="watch_activity" value="1"
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700"
|
||||
{% if alert and alert.watch_activity %}checked{% endif %}>
|
||||
<label for="watch_activity" class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Afficher "Regarde ce stream" comme activité du bot Discord
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Personnalisation de l'embed Discord</h3>
|
||||
|
||||
<div>
|
||||
<label for="embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre de l'embed</label>
|
||||
<input name="embed_title" id="embed_title" type="text" maxlength="256"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="{title}"
|
||||
value="{{alert.embed_title if alert else '{title}'}}"/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Variables: {title}, {user_name}, {game_name}, {stream_url}, {user_login}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="embed_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description de l'embed</label>
|
||||
<textarea name="embed_description" id="embed_description" rows="2"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all resize-y"
|
||||
placeholder="Description optionnelle">{{alert.embed_description if alert}}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="embed_color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Couleur</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input name="embed_color" id="embed_color" type="color"
|
||||
class="w-12 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
|
||||
value="#{{alert.embed_color if alert else '9146FF'}}"/>
|
||||
<input type="text" id="embed_color_text" maxlength="6"
|
||||
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
value="{{alert.embed_color if alert else '9146FF'}}" placeholder="9146FF"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="embed_author_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom de l'auteur</label>
|
||||
<input name="embed_author_name" id="embed_author_name" type="text" maxlength="256"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="{user_name}"
|
||||
value="{{alert.embed_author_name if alert}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="embed_author_icon" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icône de l'auteur (URL)</label>
|
||||
<input name="embed_author_icon" id="embed_author_icon" type="text" maxlength="512"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="Laissez vide pour l'avatar Twitch"
|
||||
value="{{alert.embed_author_icon if alert}}"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="embed_footer" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Pied de page</label>
|
||||
<input name="embed_footer" id="embed_footer" type="text" maxlength="2048"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="Texte optionnel en bas"
|
||||
value="{{alert.embed_footer if alert}}"/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="embed_thumbnail" id="embed_thumbnail"
|
||||
{% if not alert or alert.embed_thumbnail %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Miniature (preview)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="embed_image" id="embed_image"
|
||||
{% if not alert or alert.embed_image %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Image principale</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
{{ 'Enregistrer' if alert else 'Ajouter l\'alerte' }}
|
||||
</button>
|
||||
{% if alert %}
|
||||
<a href="{{ url_for('openLiveAlert') }}"
|
||||
class="px-6 py-2.5 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 font-medium rounded-lg transition-colors">
|
||||
Annuler
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-4">Prévisualisation de l'embed Discord</h3>
|
||||
<div id="embed-preview" class="bg-[#2f3136] rounded p-4 font-sans text-[#dcddde] max-w-xl border-l-4" style="border-left-color: #9146FF;">
|
||||
<div id="embed-author" class="flex items-center mb-2 text-sm">
|
||||
<img id="embed-author-icon" src="https://static-cdn.jtvnw.net/ttv-favicon/favicon-32x32.png" class="w-5 h-5 rounded-full mr-2" onerror="this.style.display='none'"/>
|
||||
<span id="embed-author-name" class="font-semibold">Nom du streamer</span>
|
||||
</div>
|
||||
<a id="embed-title" href="#" class="text-[#00aff4] no-underline text-base font-semibold block mb-2">Titre du stream</a>
|
||||
<div id="embed-description" class="text-sm leading-relaxed mb-2 text-[#dcddde]"></div>
|
||||
<div id="embed-thumbnail-container" class="my-2">
|
||||
<img id="embed-thumbnail" src="" class="max-w-[80px] max-h-[80px] rounded float-right ml-4 hidden"/>
|
||||
</div>
|
||||
<div id="embed-image-container" class="mt-4">
|
||||
<img id="embed-image" src="https://static-cdn.jtvnw.net/previews-ttv/live_user_chaine-320x180.jpg" class="max-w-full rounded hidden"/>
|
||||
</div>
|
||||
<div id="embed-footer" class="mt-2 text-xs text-[#72767d]"></div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Cette prévisualisation est approximative.</p>
|
||||
|
||||
<div class="mt-6 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-gray-800 dark:text-gray-200 mb-2">Variables disponibles (embed)</h4>
|
||||
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{user_login}</code> — Login Twitch</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{user_name}</code> — Nom d'affichage</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{game_name}</code> — Jeu en cours</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{title}</code> — Titre du stream</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{language}</code> — Langue</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{stream_url}</code> — Lien Twitch</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{thumbnail}</code> — URL preview</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function formatText(text, vars) {
|
||||
if (!text) return '';
|
||||
return text.replace(/\{(\w+)\}/g, function(match, key) {
|
||||
return vars[key] !== undefined && vars[key] !== null ? vars[key] : match;
|
||||
});
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
const embedTitle = document.getElementById('embed_title').value || '{title}';
|
||||
const embedDescription = document.getElementById('embed_description').value || '';
|
||||
const embedColor = document.getElementById('embed_color_text').value || '9146FF';
|
||||
const embedAuthorName = document.getElementById('embed_author_name').value || '{user_name}';
|
||||
const embedAuthorIcon = document.getElementById('embed_author_icon').value || '';
|
||||
const embedFooter = document.getElementById('embed_footer').value || '';
|
||||
const embedThumbnail = document.getElementById('embed_thumbnail').checked;
|
||||
const embedImage = document.getElementById('embed_image').checked;
|
||||
|
||||
const vars = {
|
||||
user_login: 'chainesteve',
|
||||
user_name: 'ChaîneSteve',
|
||||
game_name: 'Minecraft',
|
||||
title: '🔴 Live chill avec les viewers',
|
||||
language: 'fr',
|
||||
stream_url: 'https://www.twitch.tv/chainesteve',
|
||||
thumbnail: 'https://static-cdn.jtvnw.net/previews-ttv/live_user_chainesteve-320x180.jpg'
|
||||
};
|
||||
|
||||
document.getElementById('embed-title').textContent = formatText(embedTitle, vars);
|
||||
document.getElementById('embed-title').href = vars.stream_url;
|
||||
document.getElementById('embed-description').textContent = formatText(embedDescription, vars);
|
||||
document.getElementById('embed-author-name').textContent = formatText(embedAuthorName, vars);
|
||||
document.getElementById('embed-author-icon').src = embedAuthorIcon || 'https://static-cdn.jtvnw.net/ttv-favicon/favicon-32x32.png';
|
||||
document.getElementById('embed-author-icon').style.display = embedAuthorIcon ? '' : 'none';
|
||||
document.getElementById('embed-footer').textContent = formatText(embedFooter, vars);
|
||||
|
||||
document.getElementById('embed-preview').style.borderLeftColor = '#' + embedColor;
|
||||
|
||||
if (embedThumbnail) {
|
||||
document.getElementById('embed-thumbnail').src = vars.thumbnail;
|
||||
document.getElementById('embed-thumbnail').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('embed-thumbnail').style.display = 'none';
|
||||
}
|
||||
|
||||
if (embedImage) {
|
||||
document.getElementById('embed-image').src = vars.thumbnail;
|
||||
document.getElementById('embed-image').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('embed-image').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const colorInput = document.getElementById('embed_color');
|
||||
if (colorInput) {
|
||||
colorInput.addEventListener('input', function(e) {
|
||||
document.getElementById('embed_color_text').value = e.target.value.substring(1).toUpperCase();
|
||||
updatePreview();
|
||||
});
|
||||
}
|
||||
|
||||
const colorText = document.getElementById('embed_color_text');
|
||||
if (colorText) {
|
||||
colorText.addEventListener('input', function(e) {
|
||||
const val = e.target.value.replace(/[^0-9A-Fa-f]/g, '').substring(0, 6);
|
||||
e.target.value = val;
|
||||
if (val.length === 6) {
|
||||
document.getElementById('embed_color').value = '#' + val;
|
||||
updatePreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const formFields = ['embed_title', 'embed_description', 'embed_author_name', 'embed_author_icon', 'embed_footer', 'embed_thumbnail', 'embed_image'];
|
||||
formFields.forEach(function(field) {
|
||||
const el = document.getElementById(field);
|
||||
if (el) {
|
||||
el.addEventListener('input', updatePreview);
|
||||
el.addEventListener('change', updatePreview);
|
||||
}
|
||||
});
|
||||
|
||||
updatePreview();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
+185
-339
@@ -1,369 +1,215 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Modération Discord</h1>
|
||||
|
||||
<p>Historique des actions de modération effectuées sur le serveur Discord. Le bot enregistre automatiquement les avertissements, exclusions et bannissements.</p>
|
||||
|
||||
<!-- Navigation entre les sections -->
|
||||
<div class="moderation-nav">
|
||||
<a href="{{ url_for('moderation') }}" class="nav-btn {{ '' if show_invites else 'active' }}">📋 Événements de modération</a>
|
||||
<a href="{{ url_for('moderation_invitations') }}" class="nav-btn {{ 'active' if show_invites else '' }}">🔗 Invitations</a>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Modération</h1>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
Historique des actions de modération sur le serveur Discord.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Section Statistiques -->
|
||||
<div class="dashboard-section">
|
||||
<h2>📊 Statistiques</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📋</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Total des sanctions</span>
|
||||
<span class="stat-value big">{{ stats.total }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Top 3 sanctions</h2>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Utilisateurs les plus sanctionnés</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⏰</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Dernières 24h</span>
|
||||
<span class="stat-value big">{{ stats.recent_24h }}</span>
|
||||
<div class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% for row in top_sanctioned %}
|
||||
<div class="px-5 py-3 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="flex-shrink-0 w-7 h-7 rounded-full bg-slate-200 dark:bg-slate-600 flex items-center justify-center text-sm font-bold text-slate-700 dark:text-slate-300">{{ loop.index }}</span>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-slate-800 dark:text-white truncate">{{ row.username or '—' }}</span>
|
||||
<span class="block text-xs text-slate-500 dark:text-slate-400 font-mono truncate">{{ row.discord_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="flex-shrink-0 text-sm font-semibold text-slate-600 dark:text-slate-300">{{ row.count }} sanction{{ 's' if row.count > 1 else '' }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-5 py-6 text-center text-sm text-slate-500 dark:text-slate-400">Aucune sanction enregistrée</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📅</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">7 derniers jours</span>
|
||||
<span class="stat-value big">{{ stats.recent_7d }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Top 3 modérateurs</h2>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Staff ayant effectué le plus d'actions</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📆</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">30 derniers jours</span>
|
||||
<span class="stat-value big">{{ stats.recent_30d }}</span>
|
||||
<div class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% for row in top_moderators %}
|
||||
<div class="px-5 py-3 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="flex-shrink-0 w-7 h-7 rounded-full bg-slate-200 dark:bg-slate-600 flex items-center justify-center text-sm font-bold text-slate-700 dark:text-slate-300">{{ loop.index }}</span>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-slate-800 dark:text-white truncate">{{ row.staff_name or '—' }}</span>
|
||||
<span class="block text-xs text-slate-500 dark:text-slate-400 font-mono truncate">{{ row.staff_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="flex-shrink-0 text-sm font-semibold text-slate-600 dark:text-slate-300">{{ row.count }} action{{ 's' if row.count > 1 else '' }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-5 py-6 text-center text-sm text-slate-500 dark:text-slate-400">Aucune action enregistrée</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Types de sanctions et Top modérateurs -->
|
||||
<div class="moderation-insights">
|
||||
<div class="insight-card">
|
||||
<h3>🛡️ Top Modérateurs</h3>
|
||||
{% if stats.top_moderators %}
|
||||
<div class="leaderboard">
|
||||
{% for mod_name, count in stats.top_moderators %}
|
||||
<div class="leaderboard-item">
|
||||
<span class="rank">{{ loop.index }}</span>
|
||||
<span class="name">{{ mod_name }}</span>
|
||||
<span class="count">{{ count }} sanction{{ 's' if count > 1 else '' }}</span>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
|
||||
<details class="group">
|
||||
<summary class="flex items-center justify-between px-5 py-4 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
|
||||
<span class="font-medium text-slate-800 dark:text-white">Commandes de modération disponibles</span>
|
||||
<svg class="w-5 h-5 text-slate-400 group-open:rotate-180 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
|
||||
</summary>
|
||||
<div class="border-t border-slate-200 dark:border-slate-700">
|
||||
<div class="divide-y divide-slate-200 dark:divide-slate-700 text-sm">
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!averto @user raison</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Avertit un utilisateur</span>
|
||||
</div>
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!delaverto id</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Retire un avertissement</span>
|
||||
</div>
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!warnings [@user]</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Liste les événements de modération</span>
|
||||
</div>
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!inspect @user</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Informations sur un utilisateur</span>
|
||||
</div>
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!kick @user raison</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Expulse un utilisateur</span>
|
||||
</div>
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!ban @user raison</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Bannit un utilisateur</span>
|
||||
</div>
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!unban id</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Révoque un bannissement</span>
|
||||
</div>
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!banlist</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Liste des utilisateurs bannis</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Aucune donnée disponible</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="insight-card">
|
||||
<h3>⚖️ Types de sanctions</h3>
|
||||
{% if stats.type_counts %}
|
||||
<div class="type-badges">
|
||||
{% for type_name, count in stats.type_counts.items() %}
|
||||
<div class="type-badge {{ type_name | lower }}">
|
||||
<span class="type-name">{{ type_name }}</span>
|
||||
<span class="type-count">{{ count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Aucune donnée disponible</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="insight-card">
|
||||
<h3>⚠️ Utilisateurs les plus sanctionnés</h3>
|
||||
{% if stats.top_sanctioned %}
|
||||
<div class="leaderboard">
|
||||
{% for username, count in stats.top_sanctioned %}
|
||||
<div class="leaderboard-item warning">
|
||||
<span class="rank">{{ loop.index }}</span>
|
||||
<span class="name">{{ username }}</span>
|
||||
<span class="count">{{ count }} sanction{{ 's' if count > 1 else '' }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Aucune donnée disponible</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Section Commandes (collapsible) -->
|
||||
<details class="commands-section">
|
||||
<summary>
|
||||
<span class="summary-icon">📖</span>
|
||||
<span>Commandes de modération disponibles</span>
|
||||
</summary>
|
||||
<div class="commands-grid">
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!averto @utilisateur raison</code>
|
||||
</div>
|
||||
<p>Avertit un utilisateur et enregistre l'avertissement</p>
|
||||
<small>Alias : !warn, !av, !avertissement</small>
|
||||
</div>
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!delaverto id</code>
|
||||
</div>
|
||||
<p>Retire un avertissement par son ID</p>
|
||||
<small>Alias : !removewarn, !delwarn</small>
|
||||
</div>
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!warnings [@utilisateur]</code>
|
||||
</div>
|
||||
<p>Liste les événements de modération</p>
|
||||
<small>Alias : !listevent, !listwarn</small>
|
||||
</div>
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!inspect @utilisateur</code>
|
||||
</div>
|
||||
<p>Informations détaillées sur un utilisateur</p>
|
||||
<small>Aussi : !inspect id</small>
|
||||
</div>
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!kick @utilisateur raison</code>
|
||||
</div>
|
||||
<p>Expulse un utilisateur du serveur</p>
|
||||
</div>
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!ban @utilisateur raison</code>
|
||||
</div>
|
||||
<p>Bannit définitivement un utilisateur</p>
|
||||
</div>
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!unban discord_id</code>
|
||||
</div>
|
||||
<p>Révoque un bannissement</p>
|
||||
<small>Aussi : !unban #sanction_id raison</small>
|
||||
</div>
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!banlist</code>
|
||||
</div>
|
||||
<p>Liste des utilisateurs bannis</p>
|
||||
</div>
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!aide</code>
|
||||
</div>
|
||||
<p>Affiche toutes les commandes</p>
|
||||
<small>Alias : !help</small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% if not event %}
|
||||
<h2>📜 Historique des événements</h2>
|
||||
{% if events %}
|
||||
<table class="moderation">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Utilisateur</th>
|
||||
<th>Discord ID</th>
|
||||
<th>Date & Heure</th>
|
||||
<th>Raison</th>
|
||||
<th>Staff</th>
|
||||
<th>#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for mod_event in events %}
|
||||
<tr>
|
||||
<td><span class="event-type {{ mod_event.type | lower if mod_event.type else '' }}">{{ mod_event.type }}</span></td>
|
||||
<td>{{ mod_event.username }}</td>
|
||||
<td><code>{{ mod_event.discord_id }}</code></td>
|
||||
<td>{{ mod_event.created_at.strftime('%d/%m/%Y %H:%M') if mod_event.created_at else 'N/A' }}</td>
|
||||
<td>{{ mod_event.reason }}</td>
|
||||
<td>{{ mod_event.staff_name }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('open_edit_moderation_event', event_id = mod_event.id) }}" class="icon" title="Modifier">✐</a>
|
||||
<a href="{{ url_for('delete_moderation_event', event_id = mod_event.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')" class="icon" title="Supprimer">🗑</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🎉</div>
|
||||
<p>Aucun événement de modération enregistré</p>
|
||||
<small>Le serveur est sage !</small>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Événements de modération</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Type</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Utilisateur</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Date</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Raison</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Staff</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% for mod_event in events %}
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
{% if mod_event.type == 'ban' %}
|
||||
<span class="text-xs font-medium text-red-600 dark:text-red-400">Ban</span>
|
||||
{% elif mod_event.type == 'kick' %}
|
||||
<span class="text-xs font-medium text-orange-600 dark:text-orange-400">Kick</span>
|
||||
{% elif mod_event.type == 'warn' or mod_event.type == 'warning' %}
|
||||
<span class="text-xs font-medium text-yellow-600 dark:text-yellow-400">Warn</span>
|
||||
{% elif mod_event.type == 'unban' %}
|
||||
<span class="text-xs font-medium text-green-600 dark:text-green-400">Unban</span>
|
||||
{% else %}
|
||||
<span class="text-xs font-medium text-slate-600 dark:text-slate-400">{{ mod_event.type }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-slate-800 dark:text-white">{{ mod_event.username }}</span>
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400 font-mono">{{ mod_event.discord_id }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400 whitespace-nowrap">
|
||||
{{ mod_event.created_at.strftime('%d/%m/%Y %H:%M') if mod_event.created_at else 'N/A' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400 max-w-xs">
|
||||
<div class="line-clamp-2">{{ mod_event.reason }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400">
|
||||
{{ mod_event.staff_name }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a href="{{ url_for('open_edit_moderation_event', event_id = mod_event.id) }}" class="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors">
|
||||
Modifier
|
||||
</a>
|
||||
<a href="{{ url_for('delete_moderation_event', event_id = mod_event.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')" class="text-sm text-slate-500 hover:text-red-600 dark:hover:text-red-400 transition-colors">
|
||||
Supprimer
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
Aucun événement de modération
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if event %}
|
||||
<h2>✏️ Editer un événement</h2>
|
||||
<form action="{{ url_for('update_moderation_event', event_id = event.id) }}" method="POST">
|
||||
<label for="type">Type</label>
|
||||
<input name="type" type="text" value="{{ event.type }}" disabled />
|
||||
<label for="username">Utilisateur</label>
|
||||
<input name="username" type="text" value="{{ event.username }}" disabled />
|
||||
<label for="discord_id">Discord ID</label>
|
||||
<input name="discord_id" type="text" value="{{ event.discord_id }}" disabled />
|
||||
<label for="reason">Raison</label>
|
||||
<input name="reason" type="text" value="{{ event.reason }}" required="required" />
|
||||
<label for="staff_name">Staff</label>
|
||||
<input name="staff_name" type="text" value="{{ event.staff_name }}" disabled />
|
||||
<input type="Submit" value="Modifier">
|
||||
<a href="{{ url_for('moderation') }}">Annuler</a>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if show_invites %}
|
||||
<!-- Section Invitations Discord -->
|
||||
<h2>🔗 Invitations Discord</h2>
|
||||
|
||||
<div class="invites-actions">
|
||||
<a href="{{ url_for('sync_invitations') }}" class="btn btn-primary">🔄 Synchroniser les invitations</a>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="show-revoked" {{ 'checked' if show_revoked else '' }} onchange="toggleRevoked()">
|
||||
Afficher les invitations révoquées
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{% if invite_stats %}
|
||||
<!-- Statistiques des invitations -->
|
||||
<div class="dashboard-section">
|
||||
<h3>📊 Statistiques des invitations</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🔗</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Invitations actives</span>
|
||||
<span class="stat-value big">{{ invite_stats.total }}</span>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-5">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white mb-5">Modifier l'événement</h2>
|
||||
|
||||
<form action="{{ url_for('update_moderation_event', event_id = event.id) }}" method="POST" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Type</label>
|
||||
<input type="text" value="{{ event.type }}" disabled class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 cursor-not-allowed">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Staff</label>
|
||||
<input type="text" value="{{ event.staff_name }}" disabled class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 cursor-not-allowed">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Utilisateur</label>
|
||||
<input type="text" value="{{ event.username }}" disabled class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 cursor-not-allowed">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Discord ID</label>
|
||||
<input type="text" value="{{ event.discord_id }}" disabled class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 cursor-not-allowed font-mono">
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">👥</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Utilisations totales</span>
|
||||
<span class="stat-value big">{{ invite_stats.total_uses }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="reason" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Raison</label>
|
||||
<input type="text" name="reason" id="reason" value="{{ event.reason }}" required class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">♾️</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Permanentes</span>
|
||||
<span class="stat-value big">{{ invite_stats.permanent }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<a href="{{ url_for('moderation') }}" class="px-4 py-2 text-slate-700 dark:text-slate-300 text-sm font-medium rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
|
||||
Annuler
|
||||
</a>
|
||||
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⏳</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Temporaires</span>
|
||||
<span class="stat-value big">{{ invite_stats.temporary }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if invite_stats.top_inviters %}
|
||||
<div class="moderation-insights">
|
||||
<div class="insight-card">
|
||||
<h3>🏆 Top inviteurs</h3>
|
||||
<div class="leaderboard">
|
||||
{% for inviter_name, uses in invite_stats.top_inviters %}
|
||||
<div class="leaderboard-item">
|
||||
<span class="rank">{{ loop.index }}</span>
|
||||
<span class="name">{{ inviter_name }}</span>
|
||||
<span class="count">{{ uses }} membre{{ 's' if uses > 1 else '' }} invité{{ 's' if uses > 1 else '' }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Liste des invitations -->
|
||||
{% if invites %}
|
||||
<table class="moderation invites-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Canal</th>
|
||||
<th>Créateur</th>
|
||||
<th>Utilisations</th>
|
||||
<th>Max utilisations</th>
|
||||
<th>Expiration</th>
|
||||
<th>Créée le</th>
|
||||
<th>Statut</th>
|
||||
<th>#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for invite in invites %}
|
||||
<tr class="{{ 'revoked' if invite.revoked else '' }} {{ 'expired' if invite.is_expired else '' }}">
|
||||
<td><code>{{ invite.code }}</code></td>
|
||||
<td>{{ invite.channel_name or 'N/A' }}</td>
|
||||
<td>{{ invite.inviter_name or 'Inconnu' }}</td>
|
||||
<td>{{ invite.uses or 0 }}</td>
|
||||
<td>{{ invite.max_uses if invite.max_uses > 0 else '∞' }}</td>
|
||||
<td>
|
||||
{% if invite.max_age == 0 %}
|
||||
<span class="badge permanent">Jamais</span>
|
||||
{% elif invite.expires_at %}
|
||||
{{ invite.expires_at.strftime('%d/%m/%Y %H:%M') }}
|
||||
{% else %}
|
||||
N/A
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ invite.created_at.strftime('%d/%m/%Y %H:%M') if invite.created_at else 'N/A' }}</td>
|
||||
<td>
|
||||
{% if invite.revoked %}
|
||||
<span class="badge revoked">Révoquée</span>
|
||||
{% elif invite.is_expired %}
|
||||
<span class="badge expired">Expirée</span>
|
||||
{% elif invite.max_uses > 0 and invite.uses >= invite.max_uses %}
|
||||
<span class="badge used">Max atteint</span>
|
||||
{% else %}
|
||||
<span class="badge active">Active</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not invite.revoked %}
|
||||
<a href="{{ url_for('revoke_invitation', invite_code=invite.code) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir révoquer l\'invitation {{ invite.code }} ?')"
|
||||
class="icon" title="Révoquer">🗑</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔗</div>
|
||||
<p>Aucune invitation trouvée</p>
|
||||
<small>Cliquez sur "Synchroniser les invitations" pour récupérer les invitations depuis Discord</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function toggleRevoked() {
|
||||
const showRevoked = document.getElementById('show-revoked').checked;
|
||||
window.location.href = '{{ url_for("moderation_invitations") }}?show_revoked=' + showRevoked;
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
+127
-53
@@ -1,61 +1,135 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Proton DB</h1>
|
||||
<p>ProtonDB évalue la compatibilité des jeux Windows sur Linux via Steam Play.</p>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">ProtonDB</h1>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
ProtonDB évalue la compatibilité des jeux Windows sur Linux via Steam Play.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if configuration.getValue('proton_db_enable_enable') %}
|
||||
<h2>Game alias</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Alias</th>
|
||||
<th>Game</th>
|
||||
<th>#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in aliases %}
|
||||
<tr>
|
||||
<td>{{a.alias}}</td>
|
||||
<td>{{a.name}}</td>
|
||||
<td><a href="{{ url_for('delGameAlias', id = a.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet alias ?')">Supprimer</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Alias de jeux</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Alias</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Jeu</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-24">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for a in aliases %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-purple-600 dark:text-purple-400 font-mono">{{ a.alias }}</code>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-700 dark:text-gray-300">{{ a.name }}</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<a href="{{ url_for('delGameAlias', id = a.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet alias ?')"
|
||||
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400 inline-block"
|
||||
title="Supprimer">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
Aucun alias configuré. Ajoutez-en un ci-dessous.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Ajouter un Alias</h2>
|
||||
<form action="{{ url_for('addGameAlias') }}" method="POST">
|
||||
<label for="alias">Alias</label>
|
||||
<input name="alias" type="text" maxlength="32" required="required" />
|
||||
<label for="name">Nom</label>
|
||||
<input name="name" type="text" maxlength="256" required="required" />
|
||||
<input type="Submit" value="Ajouter">
|
||||
<p>Si vous créez un alias <strong>GTA : Grand Theft Auto</strong> alors si un utilisateur rentre la commande
|
||||
<strong>!protondb GTA 5</strong> cela fera une recherche sur <strong>Grand Theft Auto 5</strong>.
|
||||
</p>
|
||||
</form>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Ajouter un alias</h2>
|
||||
|
||||
<form action="{{ url_for('addGameAlias') }}" method="POST" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="alias" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Alias</label>
|
||||
<input name="alias" id="alias" type="text" maxlength="32" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="GTA"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom du jeu</label>
|
||||
<input name="name" id="name" type="text" maxlength="256" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="Grand Theft Auto"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Si vous créez un alias <code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">GTA</code> → <code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">Grand Theft Auto</code>,
|
||||
alors la commande <code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">!protondb GTA 5</code> fera une recherche sur <strong class="text-gray-800 dark:text-gray-200">Grand Theft Auto 5</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Ajouter l'alias
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>Configuration</h2>
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
<label for="proton_db_enable_enable">Activer</label>
|
||||
<input type="checkbox" name="proton_db_enable_enable" {% if configuration.getValue('proton_db_enable_enable') %}
|
||||
checked="checked" {% endif %}>
|
||||
<label>Activer la commande Proton DB</label>
|
||||
<label for="proton_db_api_id">API ID</label>
|
||||
<input name="proton_db_api_id" type="text" value="{{ configuration.getValue('proton_db_api_id') }}" />
|
||||
<label for="proton_db_api_key">Clé API</label>
|
||||
<input name="proton_db_api_key" type="text" value="{{ configuration.getValue('proton_db_api_key') }}" />
|
||||
<input type="Submit" value="Définir">
|
||||
<p>Pour trouver les clés, dans votre navigateur avec l'outil d'inspection ouvert (F12 ou clic droit > Inspecter
|
||||
l'élément dans Firefox/Chrome) faites une recherche de jeux sur protondb,
|
||||
puis cherchez les clés dans les requêtes (onglet Réseau/Network),
|
||||
<a href="/static/img/algolia-key.jpg" target="_blank">comme le montre cet exemple</a>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Configuration</h2>
|
||||
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-6">
|
||||
<div class="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<input type="checkbox" name="proton_db_enable_enable" id="proton_db_enable_enable"
|
||||
{% if configuration.getValue('proton_db_enable_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
|
||||
<label for="proton_db_enable_enable" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
Activer la commande ProtonDB
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="proton_db_api_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API ID</label>
|
||||
<input name="proton_db_api_id" id="proton_db_api_id" type="text"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
value="{{ configuration.getValue('proton_db_api_id') }}"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="proton_db_api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Clé API</label>
|
||||
<input name="proton_db_api_key" id="proton_db_api_key" type="text"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
value="{{ configuration.getValue('proton_db_api_key') }}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<p class="text-sm text-amber-800 dark:text-amber-200">
|
||||
Pour trouver les clés : ouvrez l'outil d'inspection (F12) dans votre navigateur, faites une recherche de jeux sur ProtonDB,
|
||||
puis cherchez les clés dans les requêtes (onglet Réseau/Network).
|
||||
<a href="/static/img/algolia-key.jpg" target="_blank" class="underline hover:no-underline">Voir l'exemple</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Enregistrer la configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto py-12">
|
||||
<h1 class="text-2xl font-bold text-slate-800 dark:text-white mb-6 text-center">Créer un compte</h1>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-4 space-y-2">
|
||||
{% for category, msg in messages %}
|
||||
<p class="p-3 rounded-lg text-sm {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200{% endif %}">{{ msg }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="post" action="{{ url_for('register') }}" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Nom d'utilisateur</label>
|
||||
<input type="text" id="username" name="username" required minlength="3" autocomplete="username" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="min. 3 caractères">
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Adresse e-mail</label>
|
||||
<input type="email" id="email" name="email" required autocomplete="email" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="vous@exemple.com">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Mot de passe</label>
|
||||
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="min. 8 caractères">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password_confirm" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Confirmer le mot de passe</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" required minlength="8" autocomplete="new-password" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent">
|
||||
</div>
|
||||
<button type="submit" class="w-full py-2.5 px-4 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">S'inscrire</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-slate-600 dark:text-slate-400">
|
||||
Déjà un compte ? <a href="{{ url_for('login') }}" class="text-primary-600 dark:text-primary-400 hover:underline">Se connecter</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,386 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Paramètres et Permissions</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">Configuration des rôles et des accès aux pages (super administrateur uniquement)</p>
|
||||
</div>
|
||||
<button type="button" onclick="document.getElementById('help-modal').classList.remove('hidden')"
|
||||
class="px-4 py-2 rounded-lg bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 hover:bg-primary-200 dark:hover:bg-primary-900/50 transition-colors flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Aide</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-6 space-y-2">
|
||||
{% for category, msg in messages %}
|
||||
<div class="p-4 rounded-lg {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800{% endif %}">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Inscriptions -->
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Inscriptions</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Autoriser ou bloquer la création de nouveaux comptes par les visiteurs</p>
|
||||
<form action="{{ url_for('settings_toggle_registration') }}" method="post">
|
||||
<label class="inline-flex items-center gap-3 cursor-pointer group">
|
||||
<div class="relative">
|
||||
<input type="checkbox" name="enabled" value="1" {% if registration_enabled %}checked{% endif %}
|
||||
onchange="this.form.submit()"
|
||||
class="sr-only peer">
|
||||
<div class="w-14 h-7 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-green-500 dark:peer-checked:bg-green-600 transition-colors"></div>
|
||||
<div class="absolute left-1 top-1 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-7 shadow-md"></div>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white">
|
||||
{% if registration_enabled %}✓ Inscriptions activées{% else %}✗ Inscriptions désactivées{% endif %}
|
||||
</span>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Rôles -->
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Hiérarchie des rôles</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Les rôles définissent le niveau d'accès des utilisateurs. Plus le niveau est élevé, plus les permissions sont étendues.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="space-y-3 mb-6">
|
||||
{% for r in roles %}
|
||||
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 transition-colors">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" style="background-color: {{ r.color or '#6B7280' }}20;">
|
||||
<span class="text-2xl" style="color: {{ r.color or '#6B7280' }};">●</span>
|
||||
</div>
|
||||
<div class="flex-grow min-w-0">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ r.name }}</h3>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
style="background-color: {{ r.color or '#6B7280' }}20; color: {{ r.color or '#6B7280' }};">
|
||||
Niveau {{ r.level }}
|
||||
</span>
|
||||
</div>
|
||||
{% if r.description %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ r.description }}</p>
|
||||
{% else %}
|
||||
{% set default_desc = default_roles_meta.get(r.name, {}).get('description') %}
|
||||
{% if default_desc %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ default_desc }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<details class="group">
|
||||
<summary class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 cursor-pointer list-none flex items-center gap-1">
|
||||
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>Modifier ce rôle</span>
|
||||
</summary>
|
||||
<form action="{{ url_for('settings_role_edit', role_id=r.id) }}" method="post" class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Niveau (0-99)</label>
|
||||
<input type="number" name="level" value="{{ r.level }}" min="0" max="99"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Couleur</label>
|
||||
<input type="color" name="color" value="{{ r.color or '#6B7280' }}"
|
||||
class="w-full h-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 cursor-pointer">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
|
||||
<input type="text" name="description" value="{{ r.description or '' }}" placeholder="Description du rôle..."
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||
</div>
|
||||
<div class="sm:col-span-2 flex items-center gap-2">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white text-sm font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
|
||||
Enregistrer
|
||||
</button>
|
||||
{% if r.name not in ['viewer_twitch','utilisateur_discord','moderateur_discord','expert_discord','moderateur_twitch','super_administrateur'] %}
|
||||
<button type="button" onclick="if(confirm('Supprimer ce rôle ?')) { this.closest('details').querySelector('form').setAttribute('action', '{{ url_for('settings_role_delete', role_id=r.id) }}'); this.closest('form').submit(); }"
|
||||
class="px-4 py-2 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm font-medium hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors">
|
||||
Supprimer
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<details class="bg-primary-50 dark:bg-primary-900/20 rounded-lg border border-primary-200 dark:border-primary-800">
|
||||
<summary class="px-4 py-3 cursor-pointer list-none flex items-center gap-2 text-primary-700 dark:text-primary-300 font-medium hover:text-primary-800 dark:hover:text-primary-200">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
<span>Créer un nouveau rôle</span>
|
||||
</summary>
|
||||
<form action="{{ url_for('settings_role_add') }}" method="post" class="p-4 pt-0 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Nom du rôle *</label>
|
||||
<input type="text" name="name" placeholder="ex: editeur_contenu" required minlength="2"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Niveau (0-99) *</label>
|
||||
<input type="number" name="level" value="0" min="0" max="99"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Couleur</label>
|
||||
<input type="color" name="color" value="#6B7280"
|
||||
class="w-full h-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 cursor-pointer">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Icône (optionnel)</label>
|
||||
<input type="text" name="icon" placeholder="ex: star, shield, user"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
|
||||
<input type="text" name="description" placeholder="Description du rôle..."
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
|
||||
Créer le rôle
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Permissions par page -->
|
||||
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Accès aux pages</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Définissez le rôle minimum requis pour accéder à chaque section de l'interface
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appliquer en masse -->
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/30">
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer list-none flex items-center gap-2 text-gray-700 dark:text-gray-300 font-medium hover:text-gray-900 dark:hover:text-white">
|
||||
<svg class="w-5 h-5 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>⚡ Modification en masse</span>
|
||||
</summary>
|
||||
<form action="{{ url_for('settings_permissions_bulk') }}" method="post" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
|
||||
<div class="flex flex-wrap items-center gap-4 mb-4">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<span>Appliquer le rôle :</span>
|
||||
<select name="role" required class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
|
||||
{% for r in roles %}
|
||||
<option value="{{ r.name }}" style="color: {{ r.color or '#6B7280' }};">{{ r.name }} (niveau {{ r.level }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">aux pages cochées :</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<button type="button" onclick="document.querySelectorAll('.bulk-page-cb').forEach(c => c.checked = true)"
|
||||
class="text-xs px-3 py-1.5 rounded-lg bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
|
||||
Tout cocher
|
||||
</button>
|
||||
<button type="button" onclick="document.querySelectorAll('.bulk-page-cb').forEach(c => c.checked = false)"
|
||||
class="text-xs px-3 py-1.5 rounded-lg bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
|
||||
Tout décocher
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 mb-4">
|
||||
{% for page_key, meta in page_metadata.items() %}
|
||||
<label class="flex items-center gap-2 px-3 py-2 rounded-lg bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:border-primary-300 dark:hover:border-primary-600 cursor-pointer transition-colors">
|
||||
<input type="checkbox" name="page_keys" value="{{ page_key }}" class="bulk-page-cb rounded border-gray-300 dark:border-gray-600 text-primary-600 focus:ring-primary-500">
|
||||
<span class="text-xs text-gray-700 dark:text-gray-300">{{ meta.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
|
||||
Appliquer à la sélection
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Pages par catégorie -->
|
||||
<div class="p-6">
|
||||
{% for category_key in ['general', 'content', 'moderation', 'config', 'admin'] %}
|
||||
{% if category_key in pages_by_category %}
|
||||
{% set category_info = category_labels[category_key] %}
|
||||
<div class="mb-8 last:mb-0">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-8 h-8 rounded-lg flex items-center justify-center" style="background-color: {{ category_info.color }}20;">
|
||||
<span class="text-lg" style="color: {{ category_info.color }};">●</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ category_info.label }}</h3>
|
||||
<div class="flex-grow h-px bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{% for page_data in pages_by_category[category_key] %}
|
||||
{% set page_key = page_data.key %}
|
||||
{% set meta = page_data.meta %}
|
||||
{% set perm = page_data.permission %}
|
||||
{% set min_lvl = perm.min_level if perm else 0 %}
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 transition-colors">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 dark:text-white mb-1">{{ meta.label }}</h4>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">{{ meta.description }}</p>
|
||||
</div>
|
||||
{% for r in roles %}
|
||||
{% if r.level == min_lvl %}
|
||||
<span class="flex-shrink-0 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap"
|
||||
style="background-color: {{ r.color or '#6B7280' }}20; color: {{ r.color or '#6B7280' }};">
|
||||
{{ r.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form action="{{ url_for('settings_permissions_update') }}" method="post" class="flex items-center gap-2">
|
||||
<input type="hidden" name="page_key" value="{{ page_key }}">
|
||||
<select name="role" class="flex-grow px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-xs focus:ring-2 focus:ring-primary-500">
|
||||
{% for r in roles %}
|
||||
<option value="{{ r.name }}" {% if r.level == min_lvl %}selected{% endif %}>{{ r.name }} (niv. {{ r.level }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="px-3 py-1.5 rounded-lg bg-primary-600 dark:bg-primary-500 text-white text-xs font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
|
||||
OK
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Modal d'aide -->
|
||||
<div id="help-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" onclick="document.getElementById('help-modal').classList.add('hidden')"></div>
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 pt-6 pb-4">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Guide des permissions</h3>
|
||||
<button type="button" onclick="document.getElementById('help-modal').classList.add('hidden')"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">🎭 Rôles</h4>
|
||||
<p>Les rôles définissent le niveau d'autorité d'un utilisateur. Plus le niveau est élevé, plus l'utilisateur a accès à des fonctionnalités.</p>
|
||||
<ul class="list-disc list-inside mt-2 space-y-1 ml-4">
|
||||
<li><strong>Niveau 0-1</strong> : Accès basique en lecture seule</li>
|
||||
<li><strong>Niveau 2-3</strong> : Modification de contenu et gestion basique</li>
|
||||
<li><strong>Niveau 4</strong> : Modération et configuration avancée</li>
|
||||
<li><strong>Niveau 5+</strong> : Administration complète du système</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">🔐 Permissions par page</h4>
|
||||
<p>Chaque page peut être restreinte à un rôle minimum. Un utilisateur doit avoir au moins le niveau requis pour y accéder.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">📋 Catégories</h4>
|
||||
<ul class="list-disc list-inside space-y-1 ml-4">
|
||||
<li><strong style="color: #6B7280;">●</strong> <strong>Général</strong> : Pages d'accueil et navigation</li>
|
||||
<li><strong style="color: #3B82F6;">●</strong> <strong>Contenu</strong> : Gestion des commandes, alertes, humeurs, etc.</li>
|
||||
<li><strong style="color: #EF4444;">●</strong> <strong>Modération</strong> : Outils de modération Discord/Twitch</li>
|
||||
<li><strong style="color: #8B5CF6;">●</strong> <strong>Configuration</strong> : Paramètres techniques des bots</li>
|
||||
<li><strong style="color: #F59E0B;">●</strong> <strong>Administration</strong> : Gestion des utilisateurs et permissions</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">💡 Bonnes pratiques</h4>
|
||||
<ul class="list-disc list-inside space-y-1 ml-4">
|
||||
<li>Attribuez le rôle le plus bas possible selon les besoins</li>
|
||||
<li>Testez les permissions avec un compte utilisateur avant de les déployer</li>
|
||||
<li>Documentez les rôles personnalisés avec des descriptions claires</li>
|
||||
<li>Vérifiez régulièrement les accès des utilisateurs</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4">
|
||||
<button type="button" onclick="document.getElementById('help-modal').classList.add('hidden')"
|
||||
class="w-full px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
|
||||
Compris !
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Améliorer le style du toggle switch */
|
||||
input[type="checkbox"].sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
+321
-70
@@ -1,85 +1,336 @@
|
||||
<!DOCTYPE html>
|
||||
<html color-mode="user">
|
||||
<html lang="fr" class="h-full">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>Mamie Henriette</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/style.css" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
},
|
||||
accent: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="icon" href="/static/ico/favicon.ico" type="image/x-icon">
|
||||
<link rel="shortcut icon" href="/static/ico/favicon.ico" type="image/x-icon">
|
||||
<style>
|
||||
/* Animations personnalisées */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
/* Scrollbar personnalisée */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
.dark ::-webkit-scrollbar-thumb { background: #475569; }
|
||||
.dark ::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/" class="nav-logo">
|
||||
<img src="/static/ico/favicon.ico" alt="Mamie Henriette">
|
||||
<span>Mamie Henriette</span>
|
||||
</a>
|
||||
|
||||
<input type="checkbox" id="nav-toggle" class="nav-toggle">
|
||||
<label for="nav-toggle" class="nav-toggle-label">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</label>
|
||||
|
||||
<ul class="nav-menu">
|
||||
<li class="has-submenu">
|
||||
<a href="#">Discord</a>
|
||||
<ul class="submenu">
|
||||
<li><a href="/commandes">📝 Commandes</a></li>
|
||||
<li><a href="/humeurs">😊 Humeurs</a></li>
|
||||
<li><a href="/moderation">🛡️ Modération</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="has-submenu">
|
||||
<a href="#">Twitch</a>
|
||||
<ul class="submenu">
|
||||
<li><a href="/live-alert">📺 Alerte Live</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="has-submenu">
|
||||
<a href="#">YouTube</a>
|
||||
<ul class="submenu">
|
||||
<li><a href="/youtube-alert">🎬 Alertes Vidéos</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="has-submenu">
|
||||
<a href="#">Outils</a>
|
||||
<ul class="submenu">
|
||||
<li><a href="/freegames">🎮 Jeux Gratuits</a></li>
|
||||
<li><a href="/protondb">🐧 ProtonDB</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/configurations">⚙️ Config</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
<body class="h-full bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-200">
|
||||
<!-- Navbar -->
|
||||
<nav class="fixed top-0 left-0 right-0 z-50 bg-white dark:bg-gray-800 shadow-md border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-3 group">
|
||||
<img src="/static/ico/favicon.ico" alt="Mamie Henriette" class="w-10 h-10 rounded-full ring-2 ring-slate-200 dark:ring-slate-600 group-hover:ring-slate-300 dark:group-hover:ring-slate-500 transition-all">
|
||||
<span class="font-bold text-xl text-slate-800 dark:text-white hidden sm:block">Mamie Henriette</span>
|
||||
</a>
|
||||
|
||||
<!-- Navigation Desktop -->
|
||||
<div class="hidden md:flex items-center gap-1">
|
||||
<!-- Discord (sous-menus) -->
|
||||
<div class="relative group">
|
||||
<button type="button" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
Discord
|
||||
<svg class="w-4 h-4 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
|
||||
</button>
|
||||
<div class="absolute left-0 top-full pt-1 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 min-w-[200px]">
|
||||
<a href="/humeurs" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
Humeur
|
||||
</a>
|
||||
<a href="/live-alert" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
|
||||
Notification Twitch
|
||||
</a>
|
||||
<a href="{{ url_for('open_twitch_events') }}" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
|
||||
Événements Twitch (sub, raid, clip)
|
||||
</a>
|
||||
<a href="/youtube" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
||||
Notification YouTube
|
||||
</a>
|
||||
<a href="/protondb" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
|
||||
ProtonDB
|
||||
</a>
|
||||
<a href="{{ url_for('openFreeLoot') }}" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<span class="text-lg">🎁</span>
|
||||
FreeLoot
|
||||
</a>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
|
||||
<a href="/commandes" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
|
||||
Commandes
|
||||
</a>
|
||||
<a href="/moderation" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
||||
Modération
|
||||
</a>
|
||||
<a href="/configurations#auto-rooms" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg>
|
||||
Auto Rooms
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Twitch (futur bot) -->
|
||||
<div class="relative group">
|
||||
<button type="button" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57l-.002-5.143zm3.43 0H16.714v5.143H15V4.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0H6zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714v9.429z"/></svg>
|
||||
Twitch
|
||||
<svg class="w-4 h-4 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
|
||||
</button>
|
||||
<div class="absolute left-0 top-full pt-1 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 min-w-[200px]">
|
||||
<a href="/live-alert" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
|
||||
Alerte live
|
||||
</a>
|
||||
<a href="{{ url_for('open_twitch_events') }}" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
|
||||
Événements (sub, raid, clip)
|
||||
</a>
|
||||
<a href="/announcements" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path></svg>
|
||||
Annonces
|
||||
</a>
|
||||
<a href="/twitch-moderation" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
||||
Moderation
|
||||
</a>
|
||||
<a href="/link-filter" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
|
||||
Filtre de liens
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration locale -->
|
||||
<a href="/configurations" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
||||
Configuration
|
||||
</a>
|
||||
{% if current_user.is_authenticated and current_user_level >= 5 %}
|
||||
<a href="{{ url_for('users_list') }}" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||
Utilisateurs
|
||||
</a>
|
||||
<a href="{{ url_for('settings') }}" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
||||
Paramètres
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
{% if current_user.is_authenticated %}
|
||||
<span class="hidden sm:inline text-sm text-gray-600 dark:text-gray-400" title="Rôle : {{ current_user.role }}">{{ current_user.username }}</span>
|
||||
<a href="{{ url_for('logout') }}" class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Déconnexion</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('login') }}" class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Connexion</a>
|
||||
{% if registration_enabled %}
|
||||
<a href="{{ url_for('register') }}" class="px-3 py-2 rounded-lg text-sm font-medium bg-primary-600 dark:bg-primary-500 text-white hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">Créer un compte</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<!-- Dark Mode Toggle -->
|
||||
<button onclick="toggleDarkMode()" class="p-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all" title="Mode sombre">
|
||||
<svg class="w-5 h-5 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||
<svg class="w-5 h-5 block dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
|
||||
</button>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button onclick="toggleMobileMenu()" class="md:hidden p-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div id="mobile-menu" class="hidden md:hidden border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<div class="px-4 py-3 space-y-1">
|
||||
<a href="/live-alert" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
|
||||
Alerte live
|
||||
</a>
|
||||
<a href="/announcements" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path></svg>
|
||||
Annonces Twitch
|
||||
</a>
|
||||
<a href="/twitch-moderation" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
||||
Moderation Twitch
|
||||
</a>
|
||||
<a href="/link-filter" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
|
||||
Filtre de liens
|
||||
</a>
|
||||
<a href="{{ url_for('open_twitch_events') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
|
||||
Événements Twitch (sub, raid, clip)
|
||||
</a>
|
||||
<a href="/youtube" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
||||
YouTube
|
||||
</a>
|
||||
<a href="/commandes" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
|
||||
Commandes
|
||||
</a>
|
||||
<a href="/humeurs" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
Humeurs
|
||||
</a>
|
||||
<a href="/moderation" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
||||
Modération
|
||||
</a>
|
||||
<a href="/configurations#auto-rooms" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg>
|
||||
Auto Rooms
|
||||
</a>
|
||||
<a href="/protondb" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
|
||||
ProtonDB
|
||||
</a>
|
||||
<a href="{{ url_for('openFreeLoot') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<span class="text-lg">🎁</span>
|
||||
FreeLoot
|
||||
</a>
|
||||
<a href="/configurations" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
||||
Configurations
|
||||
</a>
|
||||
{% if current_user.is_authenticated and current_user_level >= 5 %}
|
||||
<a href="{{ url_for('users_list') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||
Utilisateurs
|
||||
</a>
|
||||
<a href="{{ url_for('settings') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||
Paramètres
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('logout') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all border-t border-gray-200 dark:border-gray-700 mt-2 pt-4">
|
||||
Déconnexion ({{ current_user.username }})
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('login') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all border-t border-gray-200 dark:border-gray-700 mt-2 pt-4">Connexion</a>
|
||||
{% if registration_enabled %}
|
||||
<a href="{{ url_for('register') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-primary-600 dark:text-primary-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">Créer un compte</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="pt-20 pb-12 min-h-screen">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="fade-in">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<hr>
|
||||
<p><a href="https://github.com/skylanix/MamieHenriette" target="_blank">MamieHenriette</a> créé par la communauté <a href="https://discord.com/invite/UwAPqMJnx3" target="_blank">Discord</a> de <a href="https://www.youtube.com/@513v3" target="_blank">573v3</a> - Projet open source sous licence <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank">AGPLv3</a></p>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<img src="/static/ico/favicon.ico" alt="" class="w-6 h-6 rounded-full">
|
||||
<span>Mamie Henriette</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">
|
||||
Créé par la communauté
|
||||
<a href="https://discord.com/invite/UwAPqMJnx3" target="_blank" class="text-primary-600 dark:text-primary-400 hover:underline">Discord</a>
|
||||
de
|
||||
<a href="https://www.youtube.com/@513v3" target="_blank" class="text-primary-600 dark:text-primary-400 hover:underline">573v3</a>
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="https://github.com/skylanix/MamieHenriette" target="_blank" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path></svg>
|
||||
</a>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">AGPLv3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Dark mode
|
||||
function toggleDarkMode() {
|
||||
document.documentElement.classList.toggle('dark');
|
||||
localStorage.setItem('darkMode', document.documentElement.classList.contains('dark'));
|
||||
}
|
||||
|
||||
// Initialize dark mode from preference
|
||||
if (localStorage.getItem('darkMode') === 'true' ||
|
||||
(!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
|
||||
// Mobile menu
|
||||
function toggleMobileMenu() {
|
||||
document.getElementById('mobile-menu').classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Notifications d'événements Twitch</h1>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Configurez les notifications pour les abonnements, follows, raids et nouveaux clips.
|
||||
Pour chaque type d'événement vous pouvez activer l'envoi dans le <strong>chat Twitch</strong> et/ou dans un <strong>canal Discord</strong>.
|
||||
Les événements sub, follow et raid utilisent Twitch EventSub ; les clips sont détectés par vérification périodique.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('save_twitch_events') }}" method="POST" class="space-y-8">
|
||||
{% for cfg in configs %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-wrap gap-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ labels[cfg.event_type] }}</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="hidden" name="ev_{{ cfg.event_type }}_enable" value="0">
|
||||
<input type="checkbox" name="ev_{{ cfg.event_type }}_enable" value="1" {{ 'checked' if cfg.enable }}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer</span>
|
||||
</label>
|
||||
<a href="{{ url_for('toggle_twitch_event', event_type=cfg.event_type) }}"
|
||||
class="text-sm {{ 'text-green-600 dark:text-green-400' if cfg.enable else 'text-gray-500' }}">
|
||||
{{ 'Activé' if cfg.enable else 'Désactivé' }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Où notifier</h3>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="hidden" name="ev_{{ cfg.event_type }}_notify_twitch_chat" value="0">
|
||||
<input type="checkbox" name="ev_{{ cfg.event_type }}_notify_twitch_chat" value="1" {{ 'checked' if cfg.notify_twitch_chat }}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
|
||||
<span class="text-gray-700 dark:text-gray-300">Chat Twitch</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="hidden" name="ev_{{ cfg.event_type }}_notify_discord" value="0">
|
||||
<input type="checkbox" name="ev_{{ cfg.event_type }}_notify_discord" value="1" {{ 'checked' if cfg.notify_discord }}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
|
||||
<span class="text-gray-700 dark:text-gray-300">Discord (canal de notifs)</span>
|
||||
</label>
|
||||
<div>
|
||||
<label for="ev_{{ cfg.event_type }}_discord_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Canal Discord</label>
|
||||
<select name="ev_{{ cfg.event_type }}_discord_channel_id" id="ev_{{ cfg.event_type }}_discord_channel"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">— Choisir un canal —</option>
|
||||
{% for ch in channels %}
|
||||
<option value="{{ ch.id }}" {{ 'selected' if cfg.discord_channel_id == ch.id }}>{{ ch.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="ev_{{ cfg.event_type }}_message_twitch" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message (chat Twitch)</label>
|
||||
<input type="text" name="ev_{{ cfg.event_type }}_message_twitch" id="ev_{{ cfg.event_type }}_message_twitch" maxlength="500"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
value="{{ cfg.message_twitch }}" placeholder="Merci {user} !">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Sub/Follow: <code>{user}</code> <code>{user_name}</code> —
|
||||
Raid: <code>{from_broadcaster_name}</code> <code>{viewers}</code> —
|
||||
Clip: <code>{user}</code> <code>{title}</code> <code>{url}</code>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ev_{{ cfg.event_type }}_message_discord" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message Discord (optionnel, avant l'embed)</label>
|
||||
<textarea name="ev_{{ cfg.event_type }}_message_discord" id="ev_{{ cfg.event_type }}_message_discord" rows="2" maxlength="2000"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-y">{{ cfg.message_discord or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<h4 class="font-medium text-gray-800 dark:text-gray-200 mb-3">Embed Discord (optionnel)</h4>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="ev_{{ cfg.event_type }}_embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titre</label>
|
||||
<input type="text" name="ev_{{ cfg.event_type }}_embed_title" id="ev_{{ cfg.event_type }}_embed_title" maxlength="256"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
value="{{ cfg.embed_title or '' }}" placeholder="Ex: Nouveau clip">
|
||||
</div>
|
||||
<div>
|
||||
<label for="ev_{{ cfg.event_type }}_embed_color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Couleur (hex)</label>
|
||||
<input type="text" name="ev_{{ cfg.event_type }}_embed_color" id="ev_{{ cfg.event_type }}_embed_color" maxlength="6"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono"
|
||||
value="{{ cfg.embed_color or '9146FF' }}" placeholder="9146FF">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="ev_{{ cfg.event_type }}_embed_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
|
||||
<textarea name="ev_{{ cfg.event_type }}_embed_description" id="ev_{{ cfg.event_type }}_embed_description" rows="2" maxlength="2000"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-y">{{ cfg.embed_description or '' }}</textarea>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 mt-3 cursor-pointer">
|
||||
<input type="hidden" name="ev_{{ cfg.event_type }}_embed_thumbnail" value="0">
|
||||
<input type="checkbox" name="ev_{{ cfg.event_type }}_embed_thumbnail" value="1" {{ 'checked' if cfg.embed_thumbnail }}
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Miniature dans l'embed (clips)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Enregistrer tout
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,964 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.panel-moderation {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 56%) 1fr;
|
||||
gap: 1rem;
|
||||
min-height: calc(100vh - 8rem);
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.panel-moderation { grid-template-columns: 1fr; }
|
||||
}
|
||||
.player-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 56.25%;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: #0e0e10;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.25);
|
||||
}
|
||||
.player-wrap iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
.chat-wrap {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #18181b;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.2);
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chat-wrap iframe {
|
||||
flex: 1;
|
||||
min-height: 280px;
|
||||
border: none;
|
||||
}
|
||||
.mod-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #1f1f23;
|
||||
border-bottom: 1px solid #2d2d35;
|
||||
}
|
||||
.mod-toolbar input[type="text"] {
|
||||
width: 140px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #3d3d48;
|
||||
background: #18181b;
|
||||
color: #efeff1;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mod-toolbar input::placeholder { color: #6b6b7b; }
|
||||
.mod-toolbar button {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background .15s, transform .05s;
|
||||
}
|
||||
.mod-toolbar button:active { transform: scale(0.98); }
|
||||
.btn-timeout { background: #e0a82e; color: #1f1f23; }
|
||||
.btn-timeout:hover { background: #e8b84a; }
|
||||
.btn-ban { background: #eb0400; color: #fff; }
|
||||
.btn-ban:hover { background: #ff1a15; }
|
||||
.btn-unban { background: #338847; color: #fff; }
|
||||
.btn-unban:hover { background: #3da352; }
|
||||
.btn-clean { background: #53535f; color: #efeff1; }
|
||||
.btn-clean:hover { background: #63636f; }
|
||||
.btn-copy { background: #9147ff; color: #fff; }
|
||||
.btn-copy:hover { background: #a970ff; }
|
||||
.right-col { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
|
||||
.commandes-logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.section-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.06);
|
||||
}
|
||||
.dark .section-card {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.2);
|
||||
}
|
||||
.section-card-header {
|
||||
padding: 10px 14px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.dark .section-card-header {
|
||||
background: #111827;
|
||||
color: #f3f4f6;
|
||||
border-color: #374151;
|
||||
}
|
||||
.section-card-body {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
.section-card-body-logs {
|
||||
max-height: 280px;
|
||||
}
|
||||
.cmd-table, .logs-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.cmd-table th, .logs-table th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dark .cmd-table th, .dark .logs-table th { background: #374151; color: #9ca3af; }
|
||||
.cmd-table td, .logs-table td { padding: 6px 12px; border-bottom: 1px solid #e5e7eb; color: #374151; }
|
||||
.dark .cmd-table td, .dark .logs-table td { border-color: #374151; color: #e5e7eb; }
|
||||
.cmd-table tr:hover, .logs-table tr:hover { background: #f9fafb; }
|
||||
.dark .cmd-table tr:hover, .dark .logs-table tr:hover { background: #374151; }
|
||||
.page-title-mod { font-size: 1.25rem; margin-bottom: 0.5rem; }
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h1 class="page-title-mod font-bold text-gray-900 dark:text-white">Modération Twitch</h1>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Commandes et logs de modération pour {{ twitch_channel }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Info bricks: Live status, Link filter, Banned words -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Live Status -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg {{ 'bg-red-100 dark:bg-red-900/30' if is_live else 'bg-gray-100 dark:bg-gray-700' }}">
|
||||
<svg class="w-5 h-5 {{ 'text-red-600 dark:text-red-400' if is_live else 'text-gray-500' }}" fill="currentColor" viewBox="0 0 20 20">
|
||||
<circle cx="10" cy="10" r="8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white text-sm">Live</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ 'En ligne' if is_live else 'Hors ligne' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if is_live %}
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-bold text-purple-600 dark:text-purple-400">{{ viewer_count }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">viewers</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link Filter -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg {{ 'bg-green-100 dark:bg-green-900/30' if link_filter_enabled else 'bg-gray-100 dark:bg-gray-700' }}">
|
||||
<svg class="w-5 h-5 {{ 'text-green-600 dark:text-green-400' if link_filter_enabled else 'text-gray-500' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white text-sm">Filtre de liens</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ 'Actif' if link_filter_enabled else 'Inactif' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('link_filter') }}" class="text-xs text-purple-600 dark:text-purple-400 hover:underline">Configurer</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Banned Words -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg {{ 'bg-orange-100 dark:bg-orange-900/30' if banned_words else 'bg-gray-100 dark:bg-gray-700' }}">
|
||||
<svg class="w-5 h-5 {{ 'text-orange-600 dark:text-orange-400' if banned_words else 'text-gray-500' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white text-sm">Mots interdits</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ banned_words|length }} mot{{ 's' if banned_words|length > 1 else '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commandes & logs Twitch (sans lecteur/iframe) -->
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<!-- Commandes de modération -->
|
||||
<div class="section-card">
|
||||
<div class="section-card-header flex items-center justify-between">
|
||||
<span>Commandes de modération</span>
|
||||
<input type="text" id="searchInput" placeholder="Rechercher..." class="w-36 px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
<div class="section-card-body">
|
||||
<table class="cmd-table" id="commandsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Commande(s)</th>
|
||||
<th>Usage</th>
|
||||
<th>Permission</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="commandsBody">
|
||||
{% for cmd in commands %}
|
||||
<tr class="command-row" data-search="{{ cmd.commands|join(' ') }} {{ cmd.usage }} {{ cmd.description }}">
|
||||
<td>
|
||||
{% for c in cmd.commands %}
|
||||
<code class="px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded text-xs font-mono">{{ c }}</code>
|
||||
{% if not loop.last %} {% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td><code class="text-xs font-mono text-gray-600 dark:text-gray-400">{{ cmd.usage }}</code></td>
|
||||
<td><span class="text-xs text-gray-500 dark:text-gray-400">{{ cmd.permission }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs de modération -->
|
||||
<div class="section-card">
|
||||
<div class="section-card-header flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||
<span>Logs de modération (<span id="logsCount">{{ logs|length }}</span>)</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text" id="logsSearchInput" placeholder="Rechercher..." class="w-32 px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
|
||||
{% if logs %}
|
||||
<a href="{{ url_for('clear_twitch_logs') }}" onclick="return confirm('Effacer tous les logs ?')" class="text-xs text-red-600 hover:text-red-700">Effacer</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body">
|
||||
{% if logs %}
|
||||
<table class="logs-table" id="logsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Action</th>
|
||||
<th>Modo</th>
|
||||
<th>Cible</th>
|
||||
<th>Détails</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logsBody">
|
||||
{% for log in logs %}
|
||||
<tr class="log-row" data-search="{{ log.action }} {{ log.moderator }} {{ log.target or '' }} {{ log.details or '' }}">
|
||||
<td class="whitespace-nowrap text-gray-500 dark:text-gray-400">{{ log.created_at.strftime('%d/%m %H:%M') }}</td>
|
||||
<td><code class="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs">{{ log.action }}</code></td>
|
||||
<td>{{ log.moderator }}</td>
|
||||
<td>{{ log.target or '-' }}</td>
|
||||
<td class="max-w-[120px] truncate" title="{{ log.details or '' }}">{{ log.details or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400">Aucun log</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lecteur Live + Chat Twitch (si live en cours) OU Formulaire + Chat (hors live) -->
|
||||
<div class="mt-6">
|
||||
{% if is_live %}
|
||||
<!-- Layout avec lecteur vidéo (live en cours) -->
|
||||
<div class="grid lg:grid-cols-3 gap-6">
|
||||
<!-- Lecteur Twitch (2/3) -->
|
||||
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="aspect-video bg-gray-900">
|
||||
<iframe
|
||||
src="https://player.twitch.tv/?channel={{ twitch_channel }}&enableExtensions=true&muted=false&parent={{ embed_parent }}&player=popout&quality=auto&volume=0.5"
|
||||
style="width: 100%; height: 100%; border: none;"
|
||||
allowfullscreen>
|
||||
</iframe>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-700 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex h-2 w-2 relative">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">EN DIRECT</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">• {{ viewer_count }} viewers</span>
|
||||
</div>
|
||||
<a href="https://www.twitch.tv/{{ twitch_channel }}" target="_blank" class="text-xs text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||
</svg>
|
||||
Ouvrir sur Twitch
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire rapide (1/3) -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Nouvelle commande
|
||||
</h2>
|
||||
<form action="{{ url_for('add_twitch_commande') }}" method="POST" class="space-y-3">
|
||||
<input type="text" name="trigger" placeholder="!commande" required
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
|
||||
<select name="twitch_permission"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
|
||||
{% for value, label in twitch_permissions.items() %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<textarea name="response" rows="3" placeholder="Réponse... ({user} = mention)" required
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm resize-none"></textarea>
|
||||
<button type="submit" class="w-full px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium text-sm">Ajouter</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat en dessous du lecteur -->
|
||||
<div class="mt-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col" style="height: 500px;">
|
||||
{% else %}
|
||||
<!-- Layout sans lecteur (hors live) -->
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<!-- Formulaire d'ajout de commande -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Ajouter une commande
|
||||
</h2>
|
||||
<form action="{{ url_for('add_twitch_commande') }}" method="POST" class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<input type="text" name="trigger" placeholder="!commande" required
|
||||
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
|
||||
<select name="twitch_permission"
|
||||
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
|
||||
{% for value, label in twitch_permissions.items() %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<textarea name="response" rows="2" placeholder="Réponse du bot... ({user} = mention)" required
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm resize-none"></textarea>
|
||||
<button type="submit" class="w-full px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium text-sm">Ajouter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Chat -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col" style="height: 500px;">
|
||||
{% endif %}
|
||||
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">Chat en direct</h3>
|
||||
<span class="flex h-2 w-2 relative">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
</div>
|
||||
<a href="https://www.twitch.tv/{{ twitch_channel }}/chat" target="_blank" class="text-xs text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||
</svg>
|
||||
Ouvrir sur Twitch
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Zone d'affichage des messages et actions -->
|
||||
<div class="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900/50 p-4" id="chatDisplay">
|
||||
<div class="space-y-2">
|
||||
<div class="text-center text-sm text-gray-500 dark:text-gray-400 py-8">
|
||||
<div class="animate-pulse mb-4">
|
||||
<svg class="w-12 h-12 mx-auto mb-3 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-300">Récupération du chat...</p>
|
||||
<p class="text-xs mt-1">Les messages du chat Twitch apparaîtront ici en temps réel</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zone de saisie et boutons -->
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 border-t border-gray-200 dark:border-gray-600">
|
||||
<!-- Boutons d'action rapide -->
|
||||
<div class="grid grid-cols-4 gap-1.5 mb-3">
|
||||
<!-- Ligne 1 : Actions sur les users -->
|
||||
<button onclick="executeQuickAction('ban')" class="px-2 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Bannir un utilisateur">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 75.636 5.636m12.728 12.728L5.636 5.636"></path>
|
||||
</svg>
|
||||
Ban
|
||||
</button>
|
||||
<button onclick="executeQuickAction('timeout')" class="px-2 py-1.5 bg-orange-600 hover:bg-orange-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Timeout un utilisateur">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Timeout
|
||||
</button>
|
||||
<button onclick="executeQuickAction('clean')" class="px-2 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Nettoyer le chat">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
Clean
|
||||
</button>
|
||||
<button onclick="executeQuickAction('permit')" class="px-2 py-1.5 bg-teal-600 hover:bg-teal-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Permettre un lien">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||
</svg>
|
||||
Permit
|
||||
</button>
|
||||
|
||||
<!-- Ligne 2 : Modes du chat -->
|
||||
<button onclick="executeQuickAction('subon')" class="px-2 py-1.5 bg-purple-600 hover:bg-purple-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Activer mode abonnés">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
Sub ON
|
||||
</button>
|
||||
<button onclick="executeQuickAction('suboff')" class="px-2 py-1.5 bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Désactiver mode abonnés">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
Sub OFF
|
||||
</button>
|
||||
<button onclick="executeQuickAction('emoteon')" class="px-2 py-1.5 bg-yellow-600 hover:bg-yellow-700 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Activer mode emotes">
|
||||
😀 ON
|
||||
</button>
|
||||
<button onclick="executeQuickAction('emoteoff')" class="px-2 py-1.5 bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors text-xs font-medium flex items-center justify-center gap-1" title="Désactiver mode emotes">
|
||||
😀 OFF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Input de message -->
|
||||
<form onsubmit="sendMessage(event)" class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="chatMessage"
|
||||
placeholder="Message ou commande..."
|
||||
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm"
|
||||
autocomplete="off">
|
||||
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors font-medium text-sm">
|
||||
Envoyer
|
||||
</button>
|
||||
</form>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Envoyé via le bot • <a href="https://www.twitch.tv/popout/{{ twitch_channel }}/chat" target="_blank" class="text-purple-600 dark:text-purple-400 hover:underline">Ouvrir le chat</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- Fermeture du conteneur principal -->
|
||||
|
||||
{% if custom_commands %}
|
||||
<div class="mt-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">Commandes personnalisées Twitch ({{ custom_commands|length }})</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Commande</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Réponse</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Permission</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for cmd in custom_commands %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td class="px-4 py-2"><code class="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded text-xs">{{ cmd.trigger }}</code></td>
|
||||
<td class="px-4 py-2 text-gray-700 dark:text-gray-300 max-w-xs truncate">{{ cmd.response }}</td>
|
||||
<td class="px-4 py-2">{{ twitch_permissions.get(cmd.twitch_permission or 'viewer', 'Tous') }}</td>
|
||||
<td class="px-4 py-2"><a href="{{ url_for('delete_commande', commande_id=cmd.id) }}" onclick="return confirm('Supprimer ?')" class="text-red-600 hover:text-red-700 text-xs">Supprimer</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Section mots interdits -->
|
||||
<div class="mt-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">Mots interdits ({{ banned_words|length }})</h3>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire d'ajout -->
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<form action="{{ url_for('add_banned_word') }}" method="POST" class="flex flex-wrap gap-2 items-end">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label for="banned_word" class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Mot interdit</label>
|
||||
<input type="text" name="word" id="banned_word" placeholder="mot" required
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
|
||||
</div>
|
||||
<div class="w-32">
|
||||
<label for="banned_word_timeout" class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Timeout (s)</label>
|
||||
<input type="number" name="timeout_duration" id="banned_word_timeout" value="60" min="0" max="1209600"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 text-sm">
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg transition-colors font-medium text-sm">Ajouter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Liste des mots interdits -->
|
||||
{% if banned_words %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Mot</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Timeout</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Ajouté le</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for word in banned_words %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td class="px-4 py-2"><code class="px-2 py-1 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded text-xs font-mono">{{ word.word }}</code></td>
|
||||
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">{{ word.timeout_duration }}s</td>
|
||||
<td class="px-4 py-2 text-gray-500 dark:text-gray-400 text-xs">{{ word.created_at.strftime('%d/%m/%Y %H:%M') if word.created_at else '-' }}</td>
|
||||
<td class="px-4 py-2"><a href="{{ url_for('delete_banned_word', word_id=word.id) }}" onclick="return confirm('Supprimer ce mot interdit ?')" class="text-red-600 hover:text-red-700 text-xs">Supprimer</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400">Aucun mot interdit configuré</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var modTarget = document.getElementById('modTarget');
|
||||
var copyCmdBtn = document.getElementById('copyCmdBtn');
|
||||
var lastCmd = '';
|
||||
|
||||
document.querySelectorAll('.mod-toolbar button[data-cmd]').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var user = (modTarget && modTarget.value.trim()) || '';
|
||||
lastCmd = this.getAttribute('data-cmd').replace(/\{\}/g, user).trim();
|
||||
if (user && (this.classList.contains('btn-clean') && lastCmd === '!clean')) {
|
||||
lastCmd = '!clean';
|
||||
}
|
||||
navigator.clipboard && navigator.clipboard.writeText(lastCmd).then(function() {
|
||||
var t = copyCmdBtn.textContent;
|
||||
copyCmdBtn.textContent = 'Copié !';
|
||||
setTimeout(function() { copyCmdBtn.textContent = t; }, 1200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (copyCmdBtn) {
|
||||
copyCmdBtn.addEventListener('click', function() {
|
||||
if (lastCmd) {
|
||||
navigator.clipboard && navigator.clipboard.writeText(lastCmd).then(function() {
|
||||
var t = copyCmdBtn.textContent;
|
||||
copyCmdBtn.textContent = 'Copié !';
|
||||
setTimeout(function() { copyCmdBtn.textContent = t; }, 1200);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var rows = document.querySelectorAll('.command-row');
|
||||
var searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
var q = this.value.toLowerCase();
|
||||
rows.forEach(function(row) {
|
||||
row.style.display = row.dataset.search.toLowerCase().indexOf(q) >= 0 ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var logRows = document.querySelectorAll('.log-row');
|
||||
var logsSearchInput = document.getElementById('logsSearchInput');
|
||||
var logsCount = document.getElementById('logsCount');
|
||||
if (logsSearchInput) {
|
||||
logsSearchInput.addEventListener('input', function() {
|
||||
var q = this.value.toLowerCase();
|
||||
var n = 0;
|
||||
logRows.forEach(function(row) {
|
||||
var ok = row.dataset.search.toLowerCase().indexOf(q) >= 0;
|
||||
row.style.display = ok ? '' : 'none';
|
||||
if (ok) n++;
|
||||
});
|
||||
if (logsCount) logsCount.textContent = n;
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// Fonctions pour le chat Twitch
|
||||
var displayedMessages = new Set();
|
||||
var isAutoScroll = true;
|
||||
|
||||
function addMessageToDisplay(username, message, timestamp, badges, userColor) {
|
||||
var chatDisplay = document.getElementById('chatDisplay');
|
||||
var messagesContainer = chatDisplay.querySelector('.space-y-2');
|
||||
|
||||
// Supprimer le message d'aide si c'est le premier message
|
||||
var helpText = messagesContainer.querySelector('.text-center');
|
||||
if (helpText) {
|
||||
helpText.remove();
|
||||
}
|
||||
|
||||
// Créer un ID unique pour ce message
|
||||
var messageId = timestamp + username + message;
|
||||
if (displayedMessages.has(messageId)) {
|
||||
return; // Message déjà affiché
|
||||
}
|
||||
displayedMessages.add(messageId);
|
||||
|
||||
// Créer l'élément de message
|
||||
var messageDiv = document.createElement('div');
|
||||
var msgDate = new Date(timestamp);
|
||||
var timeStr = msgDate.getHours().toString().padStart(2, '0') + ':' + msgDate.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
// Déterminer les badges
|
||||
var badgeIcons = '';
|
||||
if (badges && badges.is_mod) {
|
||||
badgeIcons += '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400" title="Modérateur">MOD</span>';
|
||||
}
|
||||
if (badges && badges.is_vip) {
|
||||
badgeIcons += '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-400" title="VIP">VIP</span>';
|
||||
}
|
||||
if (badges && badges.is_subscriber) {
|
||||
badgeIcons += '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400" title="Abonné">SUB</span>';
|
||||
}
|
||||
|
||||
messageDiv.className = 'bg-white dark:bg-gray-800 rounded-lg p-3 text-sm animate-fade-in border-l-2 border-gray-300 dark:border-gray-600';
|
||||
messageDiv.style.borderLeftColor = userColor || '#9146FF';
|
||||
messageDiv.innerHTML = `
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span class="font-bold" style="color: ${userColor || '#9146FF'}">${escapeHtml(username)}</span>
|
||||
${badgeIcons}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">${timeStr}</span>
|
||||
</div>
|
||||
<div class="text-gray-900 dark:text-gray-100 break-words">${escapeHtml(message)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
messagesContainer.appendChild(messageDiv);
|
||||
|
||||
// Limiter à 100 messages affichés
|
||||
while (messagesContainer.children.length > 100) {
|
||||
messagesContainer.removeChild(messagesContainer.firstChild);
|
||||
}
|
||||
|
||||
// Scroll vers le bas si auto-scroll activé
|
||||
if (isAutoScroll) {
|
||||
chatDisplay.scrollTop = chatDisplay.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function addBotMessageToDisplay(message, type) {
|
||||
var chatDisplay = document.getElementById('chatDisplay');
|
||||
var messagesContainer = chatDisplay.querySelector('.space-y-2');
|
||||
|
||||
// Supprimer le message d'aide si c'est le premier message
|
||||
var helpText = messagesContainer.querySelector('.text-center');
|
||||
if (helpText) {
|
||||
helpText.remove();
|
||||
}
|
||||
|
||||
var now = new Date();
|
||||
var timeStr = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
var bgColor = 'bg-blue-50 dark:bg-blue-900/20';
|
||||
var borderColor = '#3b82f6';
|
||||
|
||||
if (type === 'command') {
|
||||
bgColor = 'bg-purple-50 dark:bg-purple-900/20';
|
||||
borderColor = '#9146FF';
|
||||
}
|
||||
|
||||
var messageDiv = document.createElement('div');
|
||||
messageDiv.className = `${bgColor} rounded-lg p-3 text-sm animate-fade-in border-l-2`;
|
||||
messageDiv.style.borderLeftColor = borderColor;
|
||||
messageDiv.innerHTML = `
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-bold text-purple-600 dark:text-purple-400">Bot</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">${timeStr}</span>
|
||||
</div>
|
||||
<div class="text-gray-900 dark:text-gray-100 break-words">${escapeHtml(message)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
messagesContainer.appendChild(messageDiv);
|
||||
|
||||
// Limiter à 100 messages
|
||||
while (messagesContainer.children.length > 100) {
|
||||
messagesContainer.removeChild(messagesContainer.firstChild);
|
||||
}
|
||||
|
||||
// Scroll vers le bas
|
||||
if (isAutoScroll) {
|
||||
chatDisplay.scrollTop = chatDisplay.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Polling pour récupérer les messages
|
||||
function fetchChatMessages() {
|
||||
fetch('{{ url_for("get_twitch_messages") }}')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
data.messages.forEach(function(msg) {
|
||||
addMessageToDisplay(
|
||||
msg.username,
|
||||
msg.text,
|
||||
msg.timestamp,
|
||||
{
|
||||
is_mod: msg.is_mod,
|
||||
is_vip: msg.is_vip,
|
||||
is_subscriber: msg.is_subscriber
|
||||
},
|
||||
msg.color
|
||||
);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Erreur lors de la récupération des messages:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Démarrer le polling
|
||||
setInterval(fetchChatMessages, 2000); // Toutes les 2 secondes
|
||||
fetchChatMessages(); // Premier chargement immédiat
|
||||
|
||||
// Détecter le scroll manuel pour désactiver l'auto-scroll
|
||||
document.getElementById('chatDisplay').addEventListener('scroll', function() {
|
||||
var element = this;
|
||||
var isAtBottom = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
|
||||
isAutoScroll = isAtBottom;
|
||||
});
|
||||
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function sendMessage(event) {
|
||||
event.preventDefault();
|
||||
var input = document.getElementById('chatMessage');
|
||||
var message = input.value.trim();
|
||||
|
||||
if (!message) return;
|
||||
|
||||
// Désactiver le bouton pendant l'envoi
|
||||
var submitBtn = event.target.querySelector('button[type="submit"]');
|
||||
var originalText = submitBtn.textContent;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Envoi...';
|
||||
|
||||
// Utiliser la fonction sendTextMessage pour les messages normaux
|
||||
fetch('{{ url_for("send_twitch_message") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ message: message })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Ajouter le message à l'affichage
|
||||
var type = message.startsWith('!') ? 'command' : 'message';
|
||||
addBotMessageToDisplay(message, type);
|
||||
input.value = '';
|
||||
showNotification('Message envoyé', 'success');
|
||||
} else {
|
||||
showNotification(data.error || 'Erreur lors de l\'envoi', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showNotification('Erreur réseau', 'error');
|
||||
})
|
||||
.finally(function() {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
function executeQuickAction(action) {
|
||||
// Commandes simples sans paramètres
|
||||
if (['subon', 'suboff', 'emoteon', 'emoteoff'].includes(action)) {
|
||||
executeModerationAction(action, {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Ban : demander utilisateur + raison
|
||||
if (action === 'ban') {
|
||||
var username = prompt('Nom d\'utilisateur à bannir :', '@');
|
||||
if (username && username.trim() && username !== '@') {
|
||||
username = username.trim().replace('@', '');
|
||||
var reason = prompt('Raison (optionnelle) :', 'Violation des règles');
|
||||
executeModerationAction('ban', {
|
||||
username: username,
|
||||
reason: reason || 'Ban'
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Timeout : demander utilisateur + durée + raison
|
||||
if (action === 'timeout') {
|
||||
var username = prompt('Nom d\'utilisateur à timeout :', '@');
|
||||
if (username && username.trim() && username !== '@') {
|
||||
username = username.trim().replace('@', '');
|
||||
var duration = prompt('Durée en minutes (défaut: 10) :', '10');
|
||||
duration = parseInt(duration) || 10;
|
||||
var reason = prompt('Raison (optionnelle) :', 'Timeout');
|
||||
executeModerationAction('timeout', {
|
||||
username: username,
|
||||
duration: duration * 60, // Convertir en secondes
|
||||
reason: reason || 'Timeout'
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean : demander si utilisateur spécifique ou tout le chat
|
||||
if (action === 'clean') {
|
||||
var username = prompt('Nom d\'utilisateur (ou laissez vide pour tout le chat) :', '');
|
||||
if (username === null) return; // Annulé
|
||||
executeModerationAction('clean', {
|
||||
username: username ? username.trim().replace('@', '') : ''
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Permit : demander utilisateur + durée
|
||||
if (action === 'permit') {
|
||||
var username = prompt('Nom d\'utilisateur à autoriser :', '@');
|
||||
if (username && username.trim() && username !== '@') {
|
||||
username = username.trim().replace('@', '');
|
||||
var duration = prompt('Durée en minutes (défaut: 1) :', '1');
|
||||
duration = parseInt(duration) || 1;
|
||||
executeModerationAction('permit', {
|
||||
username: username,
|
||||
duration: duration * 60 // Convertir en secondes
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function executeModerationAction(action, params) {
|
||||
fetch('{{ url_for("execute_moderation_action") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ action: action, params: params })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showNotification(data.message || 'Action exécutée', 'success');
|
||||
// Ajouter un message visuel dans le chat
|
||||
var actionText = action.toUpperCase();
|
||||
if (params.username) {
|
||||
actionText += ' @' + params.username;
|
||||
}
|
||||
if (params.duration) {
|
||||
actionText += ' (' + (params.duration / 60) + 'min)';
|
||||
}
|
||||
if (params.reason) {
|
||||
actionText += ' - ' + params.reason;
|
||||
}
|
||||
addBotMessageToDisplay(actionText, 'command');
|
||||
} else {
|
||||
showNotification(data.error || 'Erreur lors de l\'exécution', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showNotification('Erreur réseau', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Cette fonction est conservée pour les messages texte normaux (non-modération)
|
||||
function sendTextMessage(message) {
|
||||
fetch('{{ url_for("send_twitch_message") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ message: message })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
var type = message.startsWith('!') ? 'command' : 'message';
|
||||
addBotMessageToDisplay(message, type);
|
||||
showNotification('Message envoyé', 'success');
|
||||
} else {
|
||||
showNotification(data.error || 'Erreur lors de l\'envoi', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showNotification('Erreur réseau', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function showNotification(message, type) {
|
||||
var color = type === 'success' ? 'bg-green-500' : 'bg-red-500';
|
||||
var notification = document.createElement('div');
|
||||
notification.className = `fixed bottom-4 right-4 ${color} text-white px-4 py-2 rounded-lg shadow-lg z-50`;
|
||||
notification.textContent = message;
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(function() {
|
||||
notification.remove();
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,292 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<svg class="w-7 h-7 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Gestion des utilisateurs</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">Liste des comptes webapp et attribution des rôles</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="document.getElementById('create-user-modal').classList.remove('hidden')"
|
||||
class="flex-shrink-0 px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
<span>Créer un utilisateur</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-6 space-y-2">
|
||||
{% for category, msg in messages %}
|
||||
<div class="p-4 rounded-lg {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800{% endif %}">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
{% if users %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
Utilisateur
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
E-mail
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
Rôle actuel
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
Inscrit le
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
Modifier le rôle
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for u in users %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white font-semibold">
|
||||
{{ u.username[0].upper() }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-gray-900 dark:text-white">{{ u.username }}</div>
|
||||
{% if u.id == current_user.id %}
|
||||
<span class="text-xs text-primary-600 dark:text-primary-400 font-medium">C'est vous</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ u.email }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{% set user_role = None %}
|
||||
{% for r in roles %}
|
||||
{% if r == u.role %}
|
||||
{% set user_role = r %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if user_role %}
|
||||
{% set role_obj = namespace(found=None) %}
|
||||
{% for role_name in roles %}
|
||||
{% if role_name == user_role %}
|
||||
{% for r_obj in roles %}
|
||||
{# Obtenir l'objet WebappRole complet #}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap"
|
||||
style="background-color: #6B728020; color: #6B7280;">
|
||||
<span class="w-2 h-2 rounded-full" style="background-color: #6B7280;"></span>
|
||||
{{ role_labels.get(u.role, u.role) }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
{{ role_labels.get(u.role, u.role) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if u.created_at %}{{ u.created_at.strftime('%d/%m/%Y à %H:%M') }}{% else %}—{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<form action="{{ url_for('users_set_role', user_id=u.id) }}" method="post" class="flex items-center gap-2">
|
||||
<select name="role" class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 transition-colors"
|
||||
{% if u.id == current_user.id %}disabled title="Vous ne pouvez pas modifier votre propre rôle"{% endif %}>
|
||||
{% for r in roles %}
|
||||
<option value="{{ r }}" {% if u.role == r %}selected{% endif %}>{{ role_labels.get(r, r) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit"
|
||||
{% if u.id == current_user.id %}disabled{% endif %}
|
||||
class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white text-sm font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Appliquer
|
||||
</button>
|
||||
</form>
|
||||
{% if u.id != current_user.id %}
|
||||
<form action="{{ url_for('users_delete', user_id=u.id) }}" method="post" class="inline" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ?');">
|
||||
<button type="submit" class="px-3 py-2 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm font-medium hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors" title="Supprimer cet utilisateur">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if u.id == current_user.id %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Protection: modification/suppression impossible</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-gray-400 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-lg">Aucun utilisateur enregistré</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Légende des rôles -->
|
||||
{% if users %}
|
||||
<div class="mt-6 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800 p-6">
|
||||
<h3 class="text-lg font-semibold text-blue-900 dark:text-blue-200 mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Hiérarchie des rôles</span>
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{% for role_name in roles %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 border border-blue-100 dark:border-blue-900">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="w-3 h-3 rounded-full" style="background-color: #6B7280;"></span>
|
||||
<span class="font-medium text-gray-900 dark:text-white text-sm">{{ role_labels.get(role_name, role_name) }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 ml-5">
|
||||
{% if role_name == 'viewer_twitch' %}
|
||||
Accès minimal, consultation uniquement
|
||||
{% elif role_name == 'utilisateur_discord' %}
|
||||
Modification de contenu basique
|
||||
{% elif role_name == 'moderateur_discord' %}
|
||||
Outils de modération Discord
|
||||
{% elif role_name == 'expert_discord' %}
|
||||
Gestion avancée du contenu
|
||||
{% elif role_name == 'moderateur_twitch' %}
|
||||
Outils de modération Twitch
|
||||
{% elif role_name == 'super_administrateur' %}
|
||||
Accès complet au système
|
||||
{% else %}
|
||||
Rôle personnalisé
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-4 p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<p class="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>💡 Conseil :</strong> Vous pouvez personnaliser les rôles et leurs permissions dans la page
|
||||
<a href="{{ url_for('settings') }}" class="underline hover:text-blue-900 dark:hover:text-blue-100">Paramètres</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Modal de création d'utilisateur -->
|
||||
<div id="create-user-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" onclick="document.getElementById('create-user-modal').classList.add('hidden')"></div>
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<form action="{{ url_for('users_create') }}" method="post">
|
||||
<div class="bg-white dark:bg-gray-800 px-6 pt-6 pb-4">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Créer un utilisateur</h3>
|
||||
</div>
|
||||
<button type="button" onclick="document.getElementById('create-user-modal').classList.add('hidden')"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nom d'utilisateur <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" id="username" name="username" required minlength="3"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="ex: john_doe">
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Adresse e-mail <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="email" id="email" name="email" required
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="ex: john@example.com">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Mot de passe <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" id="password" name="password" required minlength="8"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Au moins 8 caractères">
|
||||
</div>
|
||||
<div>
|
||||
<label for="password_confirm" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirmer le mot de passe <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" required minlength="8"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Retapez le mot de passe">
|
||||
</div>
|
||||
<div>
|
||||
<label for="role" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Rôle <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select id="role" name="role" required
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
|
||||
{% for r in roles %}
|
||||
<option value="{{ r }}">{{ role_labels.get(r, r) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 flex gap-3 justify-end">
|
||||
<button type="button" onclick="document.getElementById('create-user-modal').classList.add('hidden')"
|
||||
class="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
|
||||
Créer l'utilisateur
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Animation au survol des lignes */
|
||||
tbody tr {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -1,106 +0,0 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Alertes YouTube</h1>
|
||||
|
||||
<p>
|
||||
Liste des chaînes YouTube surveillées pour les alertes de nouvelles vidéos.
|
||||
Le bot vérifie régulièrement les nouvelles vidéos publiées sur les chaînes configurées.
|
||||
Lorsqu'une nouvelle vidéo est détectée, le bot envoie une notification sur le canal Discord configuré.
|
||||
</p>
|
||||
|
||||
{% if not alert %}
|
||||
<h2>Chaînes surveillées</h2>
|
||||
<table class="youtube-alert">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chaîne</th>
|
||||
<th>Canal Discord</th>
|
||||
<th>Message</th>
|
||||
<th>Dernière vidéo</th>
|
||||
<th>#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for alert in alerts %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.youtube.com/channel/{{alert.channel_id}}" target="_blank">
|
||||
{{alert.channel_name or alert.channel_id}}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{alert.notify_channel_name}}</td>
|
||||
<td>{{alert.message[:50]}}{% if alert.message|length > 50 %}...{% endif %}</td>
|
||||
<td class="last-video">
|
||||
{% if alert.last_video_title %}
|
||||
<a href="https://www.youtube.com/watch?v={{alert.last_video_id}}" target="_blank" title="{{alert.last_video_title}}">
|
||||
{{alert.last_video_title[:40]}}{% if alert.last_video_title|length > 40 %}...{% endif %}
|
||||
</a>
|
||||
<br>
|
||||
<small>📅 {{alert.last_video_date.strftime('%d/%m/%Y à %H:%M') if alert.last_video_date else '-'}}</small>
|
||||
{% else %}
|
||||
<span class="no-video">Aucune vidéo détectée</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('toggleYoutubeAlert', id = alert.id) }}" class="icon">{{ '✅' if alert.enable else '❌' }}</a>
|
||||
<a href="{{ url_for('openEditYoutubeAlert', id = alert.id) }}" class="icon">✐</a>
|
||||
<a href="{{ url_for('delYoutubeAlert', id = alert.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette alerte ?')" class="icon">🗑</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<h2>{{ 'Editer une alerte' if alert else 'Ajouter une alerte YouTube' }}</h2>
|
||||
<form action="{{ url_for('submitEditYoutubeAlert', id = alert.id) if alert else url_for('addYoutubeAlert') }}" method="POST">
|
||||
<label for="channel_id">ID de la chaîne YouTube</label>
|
||||
<input name="channel_id" type="text" maxlength="64" required="required" value="{{alert.channel_id if alert}}" placeholder="UCxxxxxxxxxxxxxxxxxxxxxxxx"/>
|
||||
|
||||
<label for="notify_channel">Canal de Notification Discord</label>
|
||||
<select name="notify_channel">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}"{% if alert and alert.notify_channel == channel.id %}
|
||||
selected="selected" {% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="message">Message</label>
|
||||
<textarea name="message" rows="5" cols="50" required="required">{{alert.message if alert else '🎬 **Nouvelle vidéo !**\n\n**{0.author}** vient de publier : **{0.title}**\n\n👉 {0.link}'}}</textarea>
|
||||
|
||||
<input type="Submit" value="{{ 'Modifier' if alert else 'Ajouter' }}">
|
||||
|
||||
<h3>Comment trouver l'ID de chaîne YouTube ?</h3>
|
||||
<p>
|
||||
L'ID de chaîne YouTube commence par <code>UC</code> et contient 24 caractères.
|
||||
</p>
|
||||
<ol>
|
||||
<li>Allez sur la page de la chaîne YouTube</li>
|
||||
<li>Cliquez droit sur la page et choisissez "Afficher le code source"</li>
|
||||
<li>Recherchez <code>channelId</code> ou <code>externalId</code></li>
|
||||
<li>Copiez l'ID qui ressemble à : <code>UCxxxxxxxxxxxxxxxxxxxxxxxx</code></li>
|
||||
</ol>
|
||||
<p>
|
||||
Vous pouvez aussi utiliser des outils en ligne comme <a href="https://commentpicker.com/youtube-channel-id.php" target="_blank">Comment Picker</a> pour trouver l'ID.
|
||||
</p>
|
||||
|
||||
<h3>Variables disponibles pour le message</h3>
|
||||
<p>
|
||||
Pour le message vous avez accès à ces variables :
|
||||
</p>
|
||||
<ul>
|
||||
<li><code>{0.title}</code> : Titre de la vidéo</li>
|
||||
<li><code>{0.author}</code> : Nom de la chaîne</li>
|
||||
<li><code>{0.link}</code> : Lien vers la vidéo</li>
|
||||
<li><code>{0.video_id}</code> : ID de la vidéo</li>
|
||||
<li><code>{0.thumbnail}</code> : URL de la miniature</li>
|
||||
</ul>
|
||||
<p>
|
||||
Le message est au format <a href="https://commonmark.org/" target="_blank">common-mark</a> dans la limite de ce que supporte Discord.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -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
@@ -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é
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Notifications d'événements Twitch : sub, follow, raid, clip (chat + Discord)
|
||||
from flask import render_template, request, redirect, url_for
|
||||
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import TwitchEventNotification
|
||||
from discordbot import bot
|
||||
|
||||
EVENT_LABELS = {
|
||||
"sub": "Abonnement (sub)",
|
||||
"follow": "Nouveau follow",
|
||||
"raid": "Raid reçu",
|
||||
"clip": "Nouveau clip",
|
||||
}
|
||||
|
||||
|
||||
@webapp.route("/twitch-events")
|
||||
@require_page("twitch_events")
|
||||
def open_twitch_events():
|
||||
configs = TwitchEventNotification.query.order_by(TwitchEventNotification.event_type).all()
|
||||
# S'assurer qu'il existe une config par type
|
||||
existing = {c.event_type for c in configs}
|
||||
for ev in ("sub", "follow", "raid", "clip"):
|
||||
if ev not in existing:
|
||||
cfg = TwitchEventNotification(
|
||||
event_type=ev,
|
||||
message_twitch="Merci {user} !" if ev != "raid" else "Bienvenue aux viewers de {from_broadcaster_name} !",
|
||||
)
|
||||
db.session.add(cfg)
|
||||
configs.append(cfg)
|
||||
db.session.commit()
|
||||
channels = bot.getAllTextChannel()
|
||||
# Nom du canal Discord pour l'affichage
|
||||
for c in configs:
|
||||
if c.discord_channel_id:
|
||||
c.discord_channel_name = next((ch.name for ch in channels if ch.id == c.discord_channel_id), None)
|
||||
else:
|
||||
c.discord_channel_name = None
|
||||
return render_template("twitch-events.html", configs=configs, channels=channels, labels=EVENT_LABELS)
|
||||
|
||||
|
||||
@webapp.route("/twitch-events/save", methods=["POST"])
|
||||
@require_page("twitch_events")
|
||||
def save_twitch_events():
|
||||
if not can_write_page("twitch_events"):
|
||||
return render_template("403.html"), 403
|
||||
for ev in ("sub", "follow", "raid", "clip"):
|
||||
cfg = TwitchEventNotification.query.filter_by(event_type=ev).first()
|
||||
if not cfg:
|
||||
cfg = TwitchEventNotification(event_type=ev)
|
||||
db.session.add(cfg)
|
||||
prefix = f"ev_{ev}_"
|
||||
cfg.enable = request.form.get(prefix + "enable") == "1"
|
||||
cfg.notify_twitch_chat = request.form.get(prefix + "notify_twitch_chat") == "1"
|
||||
cfg.notify_discord = request.form.get(prefix + "notify_discord") == "1"
|
||||
ch_id = request.form.get(prefix + "discord_channel_id")
|
||||
cfg.discord_channel_id = int(ch_id) if ch_id and ch_id.isdigit() else None
|
||||
cfg.message_twitch = (request.form.get(prefix + "message_twitch") or "").strip()[:500]
|
||||
cfg.message_discord = (request.form.get(prefix + "message_discord") or "").strip()[:2000] or None
|
||||
embed_color = (request.form.get(prefix + "embed_color") or "9146FF").strip().lstrip("#")[:6]
|
||||
cfg.embed_color = embed_color if len(embed_color) == 6 else "9146FF"
|
||||
cfg.embed_title = (request.form.get(prefix + "embed_title") or "").strip()[:256] or None
|
||||
cfg.embed_description = (request.form.get(prefix + "embed_description") or "").strip()[:2000] or None
|
||||
cfg.embed_thumbnail = request.form.get(prefix + "embed_thumbnail") == "1"
|
||||
db.session.commit()
|
||||
return redirect(url_for("open_twitch_events"))
|
||||
|
||||
|
||||
@webapp.route("/twitch-events/toggle/<event_type>")
|
||||
@require_page("twitch_events")
|
||||
def toggle_twitch_event(event_type):
|
||||
if not can_write_page("twitch_events"):
|
||||
return render_template("403.html"), 403
|
||||
if event_type not in ("sub", "follow", "raid", "clip"):
|
||||
return redirect(url_for("open_twitch_events"))
|
||||
cfg = TwitchEventNotification.query.filter_by(event_type=event_type).first()
|
||||
if cfg:
|
||||
cfg.enable = not cfg.enable
|
||||
db.session.commit()
|
||||
return redirect(url_for("open_twitch_events"))
|
||||
@@ -0,0 +1,403 @@
|
||||
from flask import render_template, request, redirect, url_for, jsonify
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import Commande, TwitchModerationLog, TwitchLinkFilter, TwitchBannedWord
|
||||
from database.helpers import ConfigurationHelper
|
||||
from datetime import datetime, timedelta
|
||||
import asyncio
|
||||
|
||||
MODERATION_COMMANDS = [
|
||||
{
|
||||
"commands": ["!kick", "!to", "!timeout", "!tm"],
|
||||
"usage": "!timeout <viewer> [minutes] [raison]",
|
||||
"description": "Ejection temporaire d'un viewer (3 minutes par defaut) avec raison optionnelle",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!ban"],
|
||||
"usage": "!ban <viewer1> [viewer2] ...",
|
||||
"description": "Bannissement d'un ou plusieurs viewers (max 5)",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!unban"],
|
||||
"usage": "!unban <viewer1> [viewer2] ...",
|
||||
"description": "Debannissement d'un ou plusieurs viewers (max 5)",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!clean"],
|
||||
"usage": "!clean [viewer]",
|
||||
"description": "Nettoyage du chat ou des messages d'un viewer",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!shieldmode"],
|
||||
"usage": "!shieldmode <on/off>",
|
||||
"description": "Active/desactive le mode Shield de Twitch",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!settitle"],
|
||||
"usage": "!settitle <titre>",
|
||||
"description": "Changement du titre du live",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!setgame", "!setcateg"],
|
||||
"usage": "!setgame <jeu>",
|
||||
"description": "Changement du jeu/categorie du live",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!subon"],
|
||||
"usage": "!subon",
|
||||
"description": "Activation du mode abonnes uniquement",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!suboff"],
|
||||
"usage": "!suboff",
|
||||
"description": "Desactivation du mode abonnes uniquement",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!follon"],
|
||||
"usage": "!follon [minutes]",
|
||||
"description": "Activation du mode followers-only",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!folloff"],
|
||||
"usage": "!folloff",
|
||||
"description": "Desactivation du mode followers-only",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!emoteon"],
|
||||
"usage": "!emoteon",
|
||||
"description": "Activation du mode emote-only",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!emoteoff"],
|
||||
"usage": "!emoteoff",
|
||||
"description": "Desactivation du mode emote-only",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!ann"],
|
||||
"usage": "!ann <alias> <on/off/toggle>",
|
||||
"description": "Activer/desactiver/inverser une liste d'annonce par alias",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!no_game"],
|
||||
"usage": "!no_game <on/off>",
|
||||
"description": "Desactiver/activer tous les jeux de la chaine",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
{
|
||||
"commands": ["!multitwitch"],
|
||||
"usage": "!multitwitch [live1] [live2] ... | auto | reset",
|
||||
"description": "Creation d'un lien MultiTwitch. '@' = chaine actuelle, 'auto' = depuis le titre, 'reset' = reinitialiser",
|
||||
"permission": "Moderateur (creation) / Tous (affichage)"
|
||||
},
|
||||
{
|
||||
"commands": ["!permit"],
|
||||
"usage": "!permit <viewer> [minutes]",
|
||||
"description": "Autorise temporairement un viewer a poster un lien (1 minute par defaut)",
|
||||
"permission": "Moderateur"
|
||||
},
|
||||
]
|
||||
|
||||
TWITCH_PERMISSIONS = {'viewer': 'Tous (viewers)', 'sub': 'Abonnés', 'vip': 'VIP', 'moderator': 'Modérateur'}
|
||||
|
||||
|
||||
@webapp.route("/twitch-moderation")
|
||||
@require_page("twitch_moderation")
|
||||
def twitch_moderation():
|
||||
custom_commands = Commande.query.filter_by(twitch_enable=True).all()
|
||||
logs = TwitchModerationLog.query.order_by(TwitchModerationLog.created_at.desc()).limit(50).all()
|
||||
raw_channel = ConfigurationHelper().getValue("twitch_channel") or webapp.config["BOT_STATUS"].get("twitch_channel_name") or "chainesteve"
|
||||
twitch_channel = (raw_channel or "").strip().lower() or "chainesteve"
|
||||
embed_parent = request.host or "localhost"
|
||||
|
||||
# Link filter status
|
||||
link_filter_config = TwitchLinkFilter.query.first()
|
||||
link_filter_enabled = link_filter_config.enabled if link_filter_config else False
|
||||
|
||||
# Banned words
|
||||
banned_words = TwitchBannedWord.query.filter_by(enabled=True).all()
|
||||
|
||||
# Live status (from BOT_STATUS)
|
||||
bot_status = webapp.config.get("BOT_STATUS", {})
|
||||
is_live = bot_status.get("twitch_is_live", False)
|
||||
viewer_count = bot_status.get("twitch_viewer_count", 0)
|
||||
|
||||
return render_template(
|
||||
"twitch-moderation.html",
|
||||
commands=MODERATION_COMMANDS,
|
||||
custom_commands=custom_commands,
|
||||
logs=logs,
|
||||
twitch_permissions=TWITCH_PERMISSIONS,
|
||||
twitch_channel=twitch_channel,
|
||||
embed_parent=embed_parent,
|
||||
link_filter_enabled=link_filter_enabled,
|
||||
banned_words=banned_words,
|
||||
is_live=is_live,
|
||||
viewer_count=viewer_count,
|
||||
)
|
||||
|
||||
@webapp.route("/twitch-moderation/logs/clear")
|
||||
@require_page("twitch_moderation")
|
||||
def clear_twitch_logs():
|
||||
if not can_write_page("twitch_moderation"):
|
||||
return render_template("403.html"), 403
|
||||
TwitchModerationLog.query.delete()
|
||||
db.session.commit()
|
||||
return redirect(url_for('twitch_moderation'))
|
||||
|
||||
@webapp.route("/twitch-moderation/add", methods=['POST'])
|
||||
@require_page("twitch_moderation")
|
||||
def add_twitch_commande():
|
||||
if not can_write_page("twitch_moderation"):
|
||||
return render_template("403.html"), 403
|
||||
trigger = request.form.get('trigger')
|
||||
response = request.form.get('response')
|
||||
twitch_permission = request.form.get('twitch_permission') or 'viewer'
|
||||
if twitch_permission not in TWITCH_PERMISSIONS:
|
||||
twitch_permission = 'viewer'
|
||||
|
||||
if trigger and response:
|
||||
if not trigger.startswith('!'):
|
||||
trigger = '!' + trigger
|
||||
|
||||
existing = Commande.query.filter_by(trigger=trigger).first()
|
||||
if not existing:
|
||||
commande = Commande(trigger=trigger, response=response, discord_enable=False, twitch_enable=True, twitch_permission=twitch_permission)
|
||||
db.session.add(commande)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('twitch_moderation'))
|
||||
|
||||
@webapp.route("/twitch-moderation/banned-word/add", methods=['POST'])
|
||||
@require_page("twitch_moderation")
|
||||
def add_banned_word():
|
||||
if not can_write_page("twitch_moderation"):
|
||||
return render_template("403.html"), 403
|
||||
|
||||
word = request.form.get('word', '').strip().lower()
|
||||
timeout_duration = int(request.form.get('timeout_duration', 60))
|
||||
|
||||
if word:
|
||||
existing = TwitchBannedWord.query.filter_by(word=word).first()
|
||||
if not existing:
|
||||
banned_word = TwitchBannedWord(word=word, enabled=True, timeout_duration=timeout_duration)
|
||||
db.session.add(banned_word)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('twitch_moderation'))
|
||||
|
||||
@webapp.route("/twitch-moderation/banned-word/delete/<int:word_id>")
|
||||
@require_page("twitch_moderation")
|
||||
def delete_banned_word(word_id):
|
||||
if not can_write_page("twitch_moderation"):
|
||||
return render_template("403.html"), 403
|
||||
|
||||
banned_word = TwitchBannedWord.query.get_or_404(word_id)
|
||||
db.session.delete(banned_word)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('twitch_moderation'))
|
||||
|
||||
@webapp.route("/twitch-moderation/send-message", methods=['POST'])
|
||||
@require_page("twitch_moderation")
|
||||
def send_twitch_message():
|
||||
if not can_write_page("twitch_moderation"):
|
||||
return jsonify({"success": False, "error": "Permission refusée"}), 403
|
||||
|
||||
data = request.get_json()
|
||||
message = data.get('message', '').strip()
|
||||
|
||||
if not message:
|
||||
return jsonify({"success": False, "error": "Message vide"}), 400
|
||||
|
||||
# Vérifier que le bot Twitch est connecté
|
||||
from twitchbot import twitchBot
|
||||
if not hasattr(twitchBot, 'chat') or not twitchBot.chat:
|
||||
return jsonify({"success": False, "error": "Bot Twitch non connecté"}), 503
|
||||
|
||||
# Récupérer le nom du channel
|
||||
channel = ConfigurationHelper().getValue('twitch_channel')
|
||||
if not channel:
|
||||
return jsonify({"success": False, "error": "Channel Twitch non configuré"}), 400
|
||||
|
||||
# Envoyer le message de manière asynchrone
|
||||
try:
|
||||
async def send_msg():
|
||||
try:
|
||||
await twitchBot.chat.send_message(channel, message)
|
||||
return True
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
|
||||
# Exécuter la coroutine de manière synchrone
|
||||
loop = asyncio.new_event_loop()
|
||||
result = loop.run_until_complete(send_msg())
|
||||
loop.close()
|
||||
|
||||
if result is True:
|
||||
return jsonify({"success": True})
|
||||
else:
|
||||
return jsonify({"success": False, "error": f"Erreur: {result}"}), 500
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@webapp.route("/twitch-moderation/messages")
|
||||
@require_page("twitch_moderation")
|
||||
def get_twitch_messages():
|
||||
"""Retourne les derniers messages du chat Twitch"""
|
||||
messages = webapp.config["BOT_STATUS"].get("twitch_chat_messages", [])
|
||||
return jsonify({"messages": messages})
|
||||
|
||||
@webapp.route("/twitch-moderation/execute-action", methods=['POST'])
|
||||
@require_page("twitch_moderation")
|
||||
def execute_moderation_action():
|
||||
"""Exécute une action de modération directement"""
|
||||
if not can_write_page("twitch_moderation"):
|
||||
return jsonify({"success": False, "error": "Permission refusée"}), 403
|
||||
|
||||
data = request.get_json()
|
||||
action = data.get('action', '').strip()
|
||||
params = data.get('params', {})
|
||||
|
||||
if not action:
|
||||
return jsonify({"success": False, "error": "Action non spécifiée"}), 400
|
||||
|
||||
# Vérifier que le bot Twitch est connecté
|
||||
from twitchbot import twitchBot
|
||||
if not hasattr(twitchBot, 'chat') or not twitchBot.chat or not hasattr(twitchBot, 'twitch'):
|
||||
return jsonify({"success": False, "error": "Bot Twitch non connecté"}), 503
|
||||
|
||||
# Récupérer le nom du channel
|
||||
channel = ConfigurationHelper().getValue('twitch_channel')
|
||||
if not channel:
|
||||
return jsonify({"success": False, "error": "Channel Twitch non configuré"}), 400
|
||||
|
||||
# Créer un objet ChatMessage simulé pour les commandes qui en ont besoin
|
||||
from twitchAPI.chat import ChatMessage
|
||||
from types import SimpleNamespace
|
||||
|
||||
# Exécuter l'action de manière asynchrone
|
||||
try:
|
||||
async def execute_action():
|
||||
try:
|
||||
if action == 'timeout':
|
||||
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
|
||||
username = params.get('username', '').strip().lstrip('@')
|
||||
duration = int(params.get('duration', 600)) # en secondes
|
||||
reason = params.get('reason', 'Timeout')
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
|
||||
moderator_id = await _get_moderator_id(twitchBot.twitch)
|
||||
user_id = await _get_user_id(twitchBot.twitch, username)
|
||||
|
||||
if user_id:
|
||||
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason, duration=duration)
|
||||
_log_action("timeout", "WebApp", username, f"{duration}s - {reason}")
|
||||
return {"success": True, "message": f"Timeout de {username} pour {duration}s"}
|
||||
return {"success": False, "error": f"Utilisateur {username} introuvable"}
|
||||
|
||||
elif action == 'ban':
|
||||
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
|
||||
username = params.get('username', '').strip().lstrip('@')
|
||||
reason = params.get('reason', 'Ban')
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
|
||||
moderator_id = await _get_moderator_id(twitchBot.twitch)
|
||||
user_id = await _get_user_id(twitchBot.twitch, username)
|
||||
|
||||
if user_id:
|
||||
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason)
|
||||
_log_action("ban", "WebApp", username, reason)
|
||||
return {"success": True, "message": f"Ban de {username}"}
|
||||
return {"success": False, "error": f"Utilisateur {username} introuvable"}
|
||||
|
||||
elif action == 'clean':
|
||||
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
|
||||
username = params.get('username', '').strip().lstrip('@')
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
|
||||
moderator_id = await _get_moderator_id(twitchBot.twitch)
|
||||
|
||||
if username:
|
||||
user_id = await _get_user_id(twitchBot.twitch, username)
|
||||
if user_id:
|
||||
await twitchBot.twitch.delete_chat_messages(broadcaster_id, moderator_id, user_id=user_id)
|
||||
_log_action("clean", "WebApp", username)
|
||||
return {"success": True, "message": f"Messages de {username} supprimés"}
|
||||
return {"success": False, "error": f"Utilisateur {username} introuvable"}
|
||||
else:
|
||||
await twitchBot.twitch.delete_chat_messages(broadcaster_id, moderator_id)
|
||||
_log_action("clean", "WebApp", None, "Chat complet")
|
||||
return {"success": True, "message": "Chat nettoyé"}
|
||||
|
||||
elif action == 'permit':
|
||||
from database.models import TwitchPermit
|
||||
username = params.get('username', '').strip().lstrip('@').lower()
|
||||
duration = int(params.get('duration', 60)) # en secondes
|
||||
|
||||
expires_at = datetime.now() + timedelta(seconds=duration)
|
||||
|
||||
with webapp.app_context():
|
||||
existing = TwitchPermit.query.filter_by(username=username).first()
|
||||
if existing:
|
||||
existing.expires_at = expires_at
|
||||
else:
|
||||
permit = TwitchPermit(username=username, expires_at=expires_at)
|
||||
db.session.add(permit)
|
||||
db.session.commit()
|
||||
|
||||
return {"success": True, "message": f"Permit accordé à {username} pour {duration//60}min"}
|
||||
|
||||
elif action in ['subon', 'suboff', 'emoteon', 'emoteoff']:
|
||||
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _log_action
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
|
||||
moderator_id = await _get_moderator_id(twitchBot.twitch)
|
||||
|
||||
if action == 'subon':
|
||||
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=True)
|
||||
_log_action("subon", "WebApp")
|
||||
return {"success": True, "message": "Mode abonnés activé"}
|
||||
elif action == 'suboff':
|
||||
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=False)
|
||||
_log_action("suboff", "WebApp")
|
||||
return {"success": True, "message": "Mode abonnés désactivé"}
|
||||
elif action == 'emoteon':
|
||||
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=True)
|
||||
_log_action("emoteon", "WebApp")
|
||||
return {"success": True, "message": "Mode emote activé"}
|
||||
elif action == 'emoteoff':
|
||||
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=False)
|
||||
_log_action("emoteoff", "WebApp")
|
||||
return {"success": True, "message": "Mode emote désactivé"}
|
||||
|
||||
return {"success": False, "error": f"Action '{action}' non reconnue"}
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.error(f"Erreur lors de l'exécution de l'action {action}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# Exécuter la coroutine de manière synchrone
|
||||
loop = asyncio.new_event_loop()
|
||||
result = loop.run_until_complete(execute_action())
|
||||
loop.close()
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# Gestion des utilisateurs webapp (réservé super administrateur).
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page
|
||||
from database import db
|
||||
from database.models import WebappUser, WebappRole
|
||||
|
||||
ROLE_LABELS = {
|
||||
"viewer_twitch": "Viewer Twitch",
|
||||
"utilisateur_discord": "Utilisateur Discord",
|
||||
"moderateur_discord": "Modérateur Discord",
|
||||
"expert_discord": "Expert Discord",
|
||||
"moderateur_twitch": "Modérateur Twitch",
|
||||
"super_administrateur": "Super administrateur",
|
||||
}
|
||||
|
||||
|
||||
def _role_labels():
|
||||
roles = WebappRole.query.order_by(WebappRole.level).all()
|
||||
return {r.name: r.name.replace("_", " ").title() for r in roles}
|
||||
|
||||
|
||||
@webapp.route("/users")
|
||||
@require_page("users")
|
||||
def users_list():
|
||||
users = WebappUser.query.order_by(WebappUser.created_at.desc()).all()
|
||||
roles = WebappRole.query.order_by(WebappRole.level).all()
|
||||
labels = dict(ROLE_LABELS)
|
||||
labels.update(_role_labels())
|
||||
return render_template(
|
||||
"users.html",
|
||||
users=users,
|
||||
roles=[r.name for r in roles],
|
||||
role_labels=labels,
|
||||
)
|
||||
|
||||
|
||||
@webapp.route("/users/role/<int:user_id>", methods=["POST"])
|
||||
@require_page("users")
|
||||
def users_set_role(user_id):
|
||||
user = WebappUser.query.get_or_404(user_id)
|
||||
new_role = request.form.get("role")
|
||||
existing = WebappRole.query.filter_by(name=new_role).first()
|
||||
if new_role and existing:
|
||||
user.role = new_role
|
||||
db.session.commit()
|
||||
return redirect(url_for("users_list"))
|
||||
|
||||
|
||||
@webapp.route("/users/create", methods=["POST"])
|
||||
@require_page("users")
|
||||
def users_create():
|
||||
"""Création d'un utilisateur par un administrateur."""
|
||||
username = (request.form.get("username") or "").strip()
|
||||
email = (request.form.get("email") or "").strip().lower()
|
||||
password = request.form.get("password") or ""
|
||||
password_confirm = request.form.get("password_confirm") or ""
|
||||
role = request.form.get("role") or "viewer_twitch"
|
||||
|
||||
errors = []
|
||||
|
||||
# Validations
|
||||
if len(username) < 3:
|
||||
errors.append("Le nom d'utilisateur doit faire au moins 3 caractères.")
|
||||
if len(email) < 5 or "@" not in email:
|
||||
errors.append("Adresse e-mail invalide.")
|
||||
if len(password) < 8:
|
||||
errors.append("Le mot de passe doit faire au moins 8 caractères.")
|
||||
if password != password_confirm:
|
||||
errors.append("Les mots de passe ne correspondent pas.")
|
||||
if WebappUser.query.filter_by(username=username).first():
|
||||
errors.append("Ce nom d'utilisateur est déjà pris.")
|
||||
if WebappUser.query.filter_by(email=email).first():
|
||||
errors.append("Cette adresse e-mail est déjà utilisée.")
|
||||
|
||||
# Vérifier que le rôle existe
|
||||
if not WebappRole.query.filter_by(name=role).first():
|
||||
errors.append("Rôle invalide.")
|
||||
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, "error")
|
||||
return redirect(url_for("users_list"))
|
||||
|
||||
# Créer l'utilisateur
|
||||
user = WebappUser(
|
||||
username=username,
|
||||
email=email,
|
||||
password_hash=generate_password_hash(password, method="scrypt"),
|
||||
role=role,
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Utilisateur « {username} » créé avec succès.", "success")
|
||||
return redirect(url_for("users_list"))
|
||||
|
||||
|
||||
@webapp.route("/users/delete/<int:user_id>", methods=["POST"])
|
||||
@require_page("users")
|
||||
def users_delete(user_id):
|
||||
"""Suppression d'un utilisateur (sauf soi-même)."""
|
||||
from flask_login import current_user
|
||||
|
||||
user = WebappUser.query.get_or_404(user_id)
|
||||
|
||||
# Protection : impossible de se supprimer soi-même
|
||||
if user.id == current_user.id:
|
||||
flash("Vous ne pouvez pas supprimer votre propre compte.", "error")
|
||||
return redirect(url_for("users_list"))
|
||||
|
||||
username = user.username
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Utilisateur « {username} » supprimé.", "success")
|
||||
return redirect(url_for("users_list"))
|
||||
@@ -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"))
|
||||
@@ -1,88 +0,0 @@
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
|
||||
from webapp import webapp
|
||||
from database import db
|
||||
from database.models import YoutubeAlert
|
||||
from discordbot import bot
|
||||
from youtubebot.youtube_alert import _get_channel_name
|
||||
|
||||
|
||||
@webapp.route("/youtube-alert")
|
||||
def openYoutubeAlert():
|
||||
alerts: list[YoutubeAlert] = YoutubeAlert.query.all()
|
||||
channels = bot.getAllTextChannel()
|
||||
for alert in alerts:
|
||||
for channel in channels:
|
||||
if alert.notify_channel == channel.id:
|
||||
alert.notify_channel_name = channel.name
|
||||
return render_template("youtube-alert.html", alerts=alerts, channels=channels)
|
||||
|
||||
|
||||
@webapp.route("/youtube-alert/add", methods=['POST'])
|
||||
def addYoutubeAlert():
|
||||
channel_id = request.form.get('channel_id').strip()
|
||||
|
||||
existing = YoutubeAlert.query.filter_by(channel_id=channel_id).first()
|
||||
if existing:
|
||||
flash("Cette chaîne YouTube est déjà surveillée.", "error")
|
||||
return redirect(url_for("openYoutubeAlert"))
|
||||
|
||||
channel_name = _get_channel_name(channel_id)
|
||||
if not channel_name:
|
||||
flash("Impossible de trouver cette chaîne YouTube. Vérifiez l'ID de la chaîne.", "error")
|
||||
return redirect(url_for("openYoutubeAlert"))
|
||||
|
||||
alert = YoutubeAlert(
|
||||
enable=True,
|
||||
channel_id=channel_id,
|
||||
channel_name=channel_name,
|
||||
notify_channel=request.form.get('notify_channel'),
|
||||
message=request.form.get('message')
|
||||
)
|
||||
db.session.add(alert)
|
||||
db.session.commit()
|
||||
flash(f"Alerte ajoutée pour la chaîne {channel_name}.", "success")
|
||||
return redirect(url_for("openYoutubeAlert"))
|
||||
|
||||
|
||||
@webapp.route("/youtube-alert/toggle/<int:id>")
|
||||
def toggleYoutubeAlert(id):
|
||||
alert: YoutubeAlert = YoutubeAlert.query.get_or_404(id)
|
||||
alert.enable = not alert.enable
|
||||
db.session.commit()
|
||||
return redirect(url_for("openYoutubeAlert"))
|
||||
|
||||
|
||||
@webapp.route("/youtube-alert/edit/<int:id>")
|
||||
def openEditYoutubeAlert(id):
|
||||
alert = YoutubeAlert.query.get_or_404(id)
|
||||
channels = bot.getAllTextChannel()
|
||||
return render_template("youtube-alert.html", alert=alert, channels=channels)
|
||||
|
||||
|
||||
@webapp.route("/youtube-alert/edit/<int:id>", methods=['POST'])
|
||||
def submitEditYoutubeAlert(id):
|
||||
alert: YoutubeAlert = YoutubeAlert.query.get_or_404(id)
|
||||
new_channel_id = request.form.get('channel_id').strip()
|
||||
|
||||
if new_channel_id != alert.channel_id:
|
||||
channel_name = _get_channel_name(new_channel_id)
|
||||
if not channel_name:
|
||||
flash("Impossible de trouver cette chaîne YouTube. Vérifiez l'ID de la chaîne.", "error")
|
||||
return redirect(url_for("openEditYoutubeAlert", id=id))
|
||||
alert.channel_id = new_channel_id
|
||||
alert.channel_name = channel_name
|
||||
alert.last_video_id = None
|
||||
|
||||
alert.notify_channel = request.form.get('notify_channel')
|
||||
alert.message = request.form.get('message')
|
||||
db.session.commit()
|
||||
return redirect(url_for("openYoutubeAlert"))
|
||||
|
||||
|
||||
@webapp.route("/youtube-alert/del/<int:id>")
|
||||
def delYoutubeAlert(id):
|
||||
alert = YoutubeAlert.query.get_or_404(id)
|
||||
db.session.delete(alert)
|
||||
db.session.commit()
|
||||
return redirect(url_for("openYoutubeAlert"))
|
||||
@@ -1,102 +0,0 @@
|
||||
import logging
|
||||
import feedparser
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from database import db
|
||||
from database.models import YoutubeAlert
|
||||
from webapp import webapp
|
||||
|
||||
logger = logging.getLogger('youtube-alert')
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
YOUTUBE_RSS_URL = "https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class YoutubeVideo:
|
||||
video_id: str
|
||||
title: str
|
||||
author: str
|
||||
link: str
|
||||
published: datetime
|
||||
thumbnail: str
|
||||
|
||||
|
||||
def _fetch_latest_video(channel_id: str) -> YoutubeVideo | None:
|
||||
feed_url = YOUTUBE_RSS_URL.format(channel_id=channel_id)
|
||||
try:
|
||||
feed = feedparser.parse(feed_url)
|
||||
if feed.entries:
|
||||
entry = feed.entries[0]
|
||||
video_id = entry.yt_videoid
|
||||
return YoutubeVideo(
|
||||
video_id=video_id,
|
||||
title=entry.title,
|
||||
author=entry.author,
|
||||
link=entry.link,
|
||||
published=datetime(*entry.published_parsed[:6]),
|
||||
thumbnail=f"https://i.ytimg.com/vi/{video_id}/maxresdefault.jpg"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la récupération du flux RSS pour {channel_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _get_channel_name(channel_id: str) -> str | None:
|
||||
feed_url = YOUTUBE_RSS_URL.format(channel_id=channel_id)
|
||||
try:
|
||||
feed = feedparser.parse(feed_url)
|
||||
if feed.feed and hasattr(feed.feed, 'author'):
|
||||
return feed.feed.author
|
||||
if feed.entries:
|
||||
return feed.entries[0].author
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la récupération du nom de chaîne pour {channel_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def checkNewVideos(bot):
|
||||
with webapp.app_context():
|
||||
alerts: list[YoutubeAlert] = YoutubeAlert.query.filter_by(enable=True).all()
|
||||
|
||||
for alert in alerts:
|
||||
logger.info(f"Vérification de la chaîne : {alert.channel_name or alert.channel_id}")
|
||||
video = _fetch_latest_video(alert.channel_id)
|
||||
|
||||
if video:
|
||||
if not alert.channel_name:
|
||||
alert.channel_name = video.author
|
||||
|
||||
if alert.last_video_id != video.video_id:
|
||||
logger.info(f"Nouvelle vidéo détectée : {video.title}")
|
||||
|
||||
if alert.last_video_id is not None:
|
||||
await _notifyAlert(bot, alert, video)
|
||||
|
||||
alert.last_video_id = video.video_id
|
||||
alert.last_video_title = video.title
|
||||
alert.last_video_date = video.published
|
||||
else:
|
||||
logger.warning(f"Impossible de récupérer les vidéos pour {alert.channel_id}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
async def _notifyAlert(bot, alert: YoutubeAlert, video: YoutubeVideo):
|
||||
try:
|
||||
message = alert.message.format(video)
|
||||
logger.info(f"Message de notification : {message}")
|
||||
bot.loop.create_task(_sendMessage(bot, alert.notify_channel, message))
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors du formatage du message : {e}")
|
||||
|
||||
|
||||
async def _sendMessage(bot, channel: int, message: str):
|
||||
logger.info(f"Envoi de notification : {message}")
|
||||
channel_obj = bot.get_channel(channel)
|
||||
if channel_obj:
|
||||
await channel_obj.send(message)
|
||||
logger.info("Notification envoyée")
|
||||
else:
|
||||
logger.error(f"Canal Discord non trouvé : {channel}")
|
||||
Reference in New Issue
Block a user