Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cf0340a26 | ||
|
|
89845b3248 | ||
|
|
c962f57031 | ||
|
|
3da5ee1dab | ||
|
|
7d57b82b29 | ||
|
|
ae6d491c44 | ||
|
|
8eb6917337 | ||
|
|
e22f7b2d0f | ||
|
|
7fcb196bf3 | ||
|
|
e23f0a0385 | ||
|
|
4e6f8bec3a | ||
|
|
8634f946a1 | ||
|
|
69d8167581 | ||
|
|
e72647612d | ||
|
|
0c3048660a | ||
|
|
9d840e9bde | ||
|
|
769251df0d | ||
|
|
f901db1a7d | ||
|
|
2b8b725bbb | ||
|
|
3e57accb11 | ||
|
|
ecf59d645e | ||
|
|
ec296dd226 | ||
|
|
b51430e2b2 | ||
|
|
3c7d7f4e80 | ||
|
|
10923f58c3 | ||
|
|
855c13c183 | ||
|
|
bb92c3dd92 | ||
|
|
c4dab1d873 | ||
|
|
9cb4186bb8 | ||
|
|
f90c8eed81 | ||
|
|
fc274b47ae | ||
|
|
a93d5cda70 | ||
|
|
c9f27bf09e | ||
|
|
0453af255f | ||
|
|
26c9d0dc06 | ||
|
|
c265149357 | ||
|
|
47bc7146de | ||
|
|
f4c9fa2138 | ||
|
|
245aaf9c6d | ||
|
|
6ef0b6856e | ||
|
|
af86ba9a8e | ||
|
|
399b7c42fd | ||
|
|
ce5f60a2d7 | ||
|
|
1bf24d299c | ||
|
|
c8c1e0c283 | ||
|
|
d7b78ec1c4 | ||
|
|
6d364f28f1 | ||
|
|
589cb1f428 | ||
|
|
c44e624f53 | ||
|
|
2e83096550 | ||
|
|
540d23a3cf | ||
|
|
a2ade9deeb | ||
|
|
eedc7e4203 | ||
|
|
b8ead19360 | ||
|
|
64c043feb5 | ||
|
|
10dd78f630 | ||
|
|
876eb1a080 | ||
|
|
dc14b5193f | ||
|
|
137b6942ae | ||
|
|
77c3c11556 | ||
|
|
4eb7f304dc | ||
|
|
d24be973b8 | ||
|
|
5ee5c16cf3 | ||
|
|
5e4af406ff | ||
|
|
ed0bd4b661 | ||
|
|
4d8038cc77 | ||
|
|
942b23c956 | ||
|
|
651773e63d | ||
|
|
3450cd031c | ||
|
|
60b90edcb3 | ||
|
|
252e169af5 | ||
|
|
179876d2ce | ||
|
|
830ce61796 | ||
|
|
5709b1c0a3 | ||
|
|
11348bda39 | ||
|
|
f7e85bac69 | ||
|
|
d1d4e3b5a5 | ||
|
|
5aa52c8137 | ||
|
|
b274dcaa51 | ||
|
|
d941fdcf9c | ||
|
|
48531690fd | ||
|
|
9bbfa1fade | ||
|
|
920ddfa172 | ||
|
|
a8d2a0e063 | ||
|
|
f2cd19a053 | ||
|
|
4973144e54 | ||
|
|
9afd3b2588 |
+238
-5
@@ -33,7 +33,13 @@ def _set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _tableExists(table_name: str, cursor: Cursor) -> bool:
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
def _tableHaveColumn(table_name:str, column_name:str, cursor:Cursor) -> bool:
|
||||
if not _tableExists(table_name, cursor):
|
||||
return False
|
||||
cursor.execute(f'PRAGMA table_info({table_name})')
|
||||
columns = cursor.fetchall()
|
||||
return any(col[1] == column_name for col in columns)
|
||||
@@ -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,245 @@ 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}")
|
||||
|
||||
# Colonnes supplémentaires pour patreon_post (historique + statut notification)
|
||||
if _tableExists('patreon_post', cursor):
|
||||
patreon_columns = [
|
||||
('title', 'VARCHAR(512)'),
|
||||
('link', 'VARCHAR(1024)'),
|
||||
('description', 'TEXT'),
|
||||
('published_at', 'VARCHAR(64)'),
|
||||
('notified', 'BOOLEAN NOT NULL DEFAULT 0'),
|
||||
]
|
||||
for col_name, col_type in patreon_columns:
|
||||
if not _tableHaveColumn('patreon_post', col_name, cursor):
|
||||
try:
|
||||
cursor.execute(f'ALTER TABLE patreon_post ADD COLUMN {col_name} {col_type}')
|
||||
logging.info(f"Colonne {col_name} ajoutée à patreon_post")
|
||||
except Exception as e:
|
||||
logging.warning(f"Colonne patreon_post.{col_name}: {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),
|
||||
("patreon", 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 : Cursor = connection.cursor()
|
||||
cursor = connection.cursor()
|
||||
_doPreImportMigration(cursor)
|
||||
cursor.executescript(sql)
|
||||
_doPostImportMigration(cursor)
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
except Exception as e:
|
||||
logging.error(f"lors de l'import de la bdd : {e}")
|
||||
finally:
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
_doAddColumnMigrations(cursor)
|
||||
_doSeedAuth(cursor)
|
||||
connection.commit()
|
||||
except Exception as e:
|
||||
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
|
||||
);
|
||||
+217
-6
@@ -1,8 +1,66 @@
|
||||
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)
|
||||
value = db.Column(db.String(512))
|
||||
value = db.Column(db.Text)
|
||||
|
||||
class Humeur(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
@@ -25,20 +83,36 @@ class LiveAlert(db.Model):
|
||||
online = db.Column(db.Boolean, default=False)
|
||||
login = db.Column(db.String(128))
|
||||
notify_channel = db.Column(db.Integer)
|
||||
message = db.Column(db.String(2000))
|
||||
message = db.Column(db.String(2000)) # message optionnel avant l'embed
|
||||
watch_activity = db.Column(db.Boolean, default=False)
|
||||
# Personnalisation de l'embed Discord (comme YouTube)
|
||||
embed_title = db.Column(db.String(256))
|
||||
embed_description = db.Column(db.String(2000))
|
||||
embed_color = db.Column(db.String(8), default='9146FF') # violet Twitch
|
||||
embed_footer = db.Column(db.String(2048))
|
||||
embed_author_name = db.Column(db.String(256))
|
||||
embed_author_icon = db.Column(db.String(512))
|
||||
embed_thumbnail = db.Column(db.Boolean, default=True)
|
||||
embed_image = db.Column(db.Boolean, default=True)
|
||||
|
||||
class Message(db.Model):
|
||||
class TwitchAnnouncement(db.Model):
|
||||
__tablename__ = 'twitch_announcement'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
enable = db.Column(db.Boolean, default=False)
|
||||
text = db.Column(db.String(256))
|
||||
periodicity = db.Column(db.Integer)
|
||||
enable = db.Column(db.Boolean, default=True)
|
||||
name = db.Column(db.String(64))
|
||||
text = db.Column(db.String(500))
|
||||
periodicity = db.Column(db.Integer, default=10)
|
||||
min_chat_messages = db.Column(db.Integer, default=0)
|
||||
last_sent = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Niveaux de permission Twitch pour les commandes personnalisées : viewer, sub, vip, moderator
|
||||
class Commande(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
discord_enable = db.Column(db.Boolean, default=True)
|
||||
twitch_enable = db.Column(db.Boolean, default=True)
|
||||
trigger = db.Column(db.String(32), unique=True)
|
||||
response = db.Column(db.String(2000))
|
||||
twitch_permission = db.Column(db.String(16), default='viewer') # viewer | sub | vip | moderator
|
||||
|
||||
class ModerationEvent(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
@@ -51,6 +125,46 @@ class ModerationEvent(db.Model):
|
||||
staff_name = db.Column(db.String(256))
|
||||
duration = db.Column(db.Integer)
|
||||
|
||||
|
||||
class TwitchModerationLog(db.Model):
|
||||
__tablename__ = 'twitch_moderation_log'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
action = db.Column(db.String(32))
|
||||
moderator = db.Column(db.String(256))
|
||||
target = db.Column(db.String(256))
|
||||
details = db.Column(db.String(512))
|
||||
created_at = db.Column(db.DateTime)
|
||||
|
||||
|
||||
class TwitchLinkFilter(db.Model):
|
||||
__tablename__ = 'twitch_link_filter'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
enabled = db.Column(db.Boolean, default=False)
|
||||
allow_subscribers = db.Column(db.Boolean, default=True)
|
||||
allow_vips = db.Column(db.Boolean, default=True)
|
||||
allow_moderators = db.Column(db.Boolean, default=True)
|
||||
timeout_duration = db.Column(db.Integer, default=60)
|
||||
warning_message = db.Column(db.String(500), default="Les liens ne sont pas autorises dans le chat.")
|
||||
|
||||
|
||||
class TwitchAllowedDomain(db.Model):
|
||||
__tablename__ = 'twitch_allowed_domain'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
domain = db.Column(db.String(256), unique=True)
|
||||
|
||||
|
||||
class TwitchPermit(db.Model):
|
||||
__tablename__ = 'twitch_permit'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(256))
|
||||
expires_at = db.Column(db.DateTime)
|
||||
|
||||
|
||||
class TwitchAllowedUser(db.Model):
|
||||
__tablename__ = 'twitch_allowed_user'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(256), unique=True)
|
||||
|
||||
class AntiCheatCache(db.Model):
|
||||
__tablename__ = 'anticheat_cache'
|
||||
steam_id = db.Column(db.String(32), primary_key=True)
|
||||
@@ -61,3 +175,100 @@ class AntiCheatCache(db.Model):
|
||||
notes = db.Column(db.String(1024))
|
||||
updated_at = db.Column(db.DateTime)
|
||||
|
||||
|
||||
class YouTubeNotification(db.Model):
|
||||
__tablename__ = 'youtube_notification'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
enable = db.Column(db.Boolean, default=True)
|
||||
channel_id = db.Column(db.String(128))
|
||||
notify_channel = db.Column(db.Integer)
|
||||
message = db.Column(db.String(2000))
|
||||
video_type = db.Column(db.String(16), default='all')
|
||||
last_video_id = db.Column(db.String(128))
|
||||
embed_title = db.Column(db.String(256))
|
||||
embed_description = db.Column(db.String(2000))
|
||||
embed_color = db.Column(db.String(8), default='FF0000')
|
||||
embed_footer = db.Column(db.String(2048))
|
||||
embed_author_name = db.Column(db.String(256))
|
||||
embed_author_icon = db.Column(db.String(512))
|
||||
embed_thumbnail = db.Column(db.Boolean, default=True)
|
||||
embed_image = db.Column(db.Boolean, default=True)
|
||||
|
||||
|
||||
class YouTubeVideoHistory(db.Model):
|
||||
__tablename__ = 'youtube_video_history'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
notification_id = db.Column(db.Integer, db.ForeignKey('youtube_notification.id'), nullable=False)
|
||||
video_id = db.Column(db.String(128), nullable=False)
|
||||
title = db.Column(db.String(512))
|
||||
url = db.Column(db.String(512))
|
||||
channel_name = db.Column(db.String(256))
|
||||
thumbnail = db.Column(db.String(512))
|
||||
published_at = db.Column(db.String(64))
|
||||
is_short = db.Column(db.Boolean, default=False)
|
||||
notified = db.Column(db.Boolean, default=False)
|
||||
detected_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class AutoRoom(db.Model):
|
||||
"""État persistant des salons vocaux temporaires."""
|
||||
__tablename__ = 'auto_room'
|
||||
guild_id = db.Column(db.String(32), primary_key=True)
|
||||
voice_channel_id = db.Column(db.String(32), primary_key=True)
|
||||
owner_id = db.Column(db.String(32), nullable=False)
|
||||
control_message_id = db.Column(db.String(32))
|
||||
access_mode = db.Column(db.String(16), nullable=False, default='open')
|
||||
whitelist = db.Column(db.Text, nullable=False, default='[]')
|
||||
blacklist = db.Column(db.Text, nullable=False, default='[]')
|
||||
managed_member_ids = db.Column(db.Text, nullable=False, default='[]')
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class PatreonPost(db.Model):
|
||||
__tablename__ = 'patreon_post'
|
||||
guid = db.Column(db.String(512), primary_key=True)
|
||||
title = db.Column(db.String(512))
|
||||
link = db.Column(db.String(1024))
|
||||
description = db.Column(db.Text)
|
||||
published_at = db.Column(db.String(64))
|
||||
notified = db.Column(db.Boolean, default=False)
|
||||
|
||||
|
||||
class ModShoutboxMessage(db.Model):
|
||||
__tablename__ = 'mod_shoutbox_message'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
author = db.Column(db.String(64), nullable=False)
|
||||
message = db.Column(db.String(500), nullable=False)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
+172
-7
@@ -1,7 +1,7 @@
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `configuration` (
|
||||
`key` VARCHAR(32) PRIMARY KEY,
|
||||
`value` VARCHAR(512) NOT NULL
|
||||
`value` TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `game_alias` (
|
||||
@@ -28,14 +28,26 @@ CREATE TABLE IF NOT EXISTS live_alert (
|
||||
`online` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`login` VARCHAR(128) UNIQUE NOT NULL,
|
||||
`notify_channel` INTEGER NOT NULL,
|
||||
`message` VARCHAR(2000) NOT NULL
|
||||
`message` VARCHAR(2000),
|
||||
`watch_activity` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`embed_title` VARCHAR(256),
|
||||
`embed_description` VARCHAR(2000),
|
||||
`embed_color` VARCHAR(8) DEFAULT '9146FF',
|
||||
`embed_footer` VARCHAR(2048),
|
||||
`embed_author_name` VARCHAR(256),
|
||||
`embed_author_icon` VARCHAR(512),
|
||||
`embed_thumbnail` BOOLEAN DEFAULT TRUE,
|
||||
`embed_image` BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `message` (
|
||||
CREATE TABLE IF NOT EXISTS `twitch_announcement` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`enable` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`text` VARCHAR(256) NULL,
|
||||
periodicity INTEGER NULL
|
||||
`enable` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`name` VARCHAR(64) NOT NULL,
|
||||
`text` VARCHAR(500) NOT NULL,
|
||||
`periodicity` INTEGER NOT NULL DEFAULT 10,
|
||||
`min_chat_messages` INTEGER NOT NULL DEFAULT 0,
|
||||
`last_sent` DATETIME NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `commande` (
|
||||
@@ -43,7 +55,8 @@ CREATE TABLE IF NOT EXISTS `commande` (
|
||||
`discord_enable` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`twitch_enable` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`trigger` VARCHAR(16) UNIQUE NOT NULL,
|
||||
`response` VARCHAR(2000) NOT NULL
|
||||
`response` VARCHAR(2000) NOT NULL,
|
||||
`twitch_permission` VARCHAR(16) DEFAULT 'viewer'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `moderation_event` (
|
||||
@@ -58,6 +71,15 @@ CREATE TABLE IF NOT EXISTS `moderation_event` (
|
||||
`duration` INTEGER NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `twitch_moderation_log` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`action` VARCHAR(32) NOT NULL,
|
||||
`moderator` VARCHAR(256) NOT NULL,
|
||||
`target` VARCHAR(256),
|
||||
`details` VARCHAR(512),
|
||||
`created_at` DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `anticheat_cache` (
|
||||
steam_id VARCHAR(32) PRIMARY KEY,
|
||||
game_name VARCHAR(256) NOT NULL,
|
||||
@@ -76,3 +98,146 @@ CREATE TABLE IF NOT EXISTS `member_invites` (
|
||||
`inviter_name` VARCHAR(256),
|
||||
`join_date` DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `twitch_link_filter` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`enabled` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`allow_subscribers` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`allow_vips` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`allow_moderators` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`timeout_duration` INTEGER NOT NULL DEFAULT 60,
|
||||
`warning_message` VARCHAR(500) NOT NULL DEFAULT 'Les liens ne sont pas autorises dans le chat.'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `twitch_allowed_domain` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`domain` VARCHAR(256) UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `twitch_permit` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`username` VARCHAR(256) NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `twitch_allowed_user` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`username` VARCHAR(256) UNIQUE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `twitch_banned_word` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`word` VARCHAR(256) UNIQUE NOT NULL,
|
||||
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`timeout_duration` INTEGER NOT NULL DEFAULT 60,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `youtube_notification` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`enable` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`channel_id` VARCHAR(128) NOT NULL,
|
||||
`notify_channel` INTEGER NOT NULL,
|
||||
`message` VARCHAR(2000) NOT NULL,
|
||||
`video_type` VARCHAR(16) NOT NULL DEFAULT 'all',
|
||||
`last_video_id` VARCHAR(128),
|
||||
`embed_title` VARCHAR(256),
|
||||
`embed_description` VARCHAR(2000),
|
||||
`embed_color` VARCHAR(8) NOT NULL DEFAULT 'FF0000',
|
||||
`embed_footer` VARCHAR(2048),
|
||||
`embed_author_name` VARCHAR(256),
|
||||
`embed_author_icon` VARCHAR(512),
|
||||
`embed_thumbnail` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`embed_image` BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `webapp_user` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(64) UNIQUE NOT NULL,
|
||||
email VARCHAR(256) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(256) NOT NULL,
|
||||
role VARCHAR(64) NOT NULL DEFAULT 'viewer_twitch',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `webapp_role` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(64) UNIQUE NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
description VARCHAR(256) NULL,
|
||||
color VARCHAR(7) NULL DEFAULT '#6B7280',
|
||||
icon VARCHAR(32) NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `webapp_page_permission` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_key VARCHAR(64) UNIQUE NOT NULL,
|
||||
min_level INTEGER NOT NULL DEFAULT 0,
|
||||
write_level INTEGER NULL,
|
||||
category VARCHAR(32) NULL DEFAULT 'general',
|
||||
description VARCHAR(256) NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `youtube_video_history` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`notification_id` INTEGER NOT NULL,
|
||||
`video_id` VARCHAR(128) NOT NULL,
|
||||
`title` VARCHAR(512),
|
||||
`url` VARCHAR(512),
|
||||
`channel_name` VARCHAR(256),
|
||||
`thumbnail` VARCHAR(512),
|
||||
`published_at` VARCHAR(64),
|
||||
`is_short` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`notified` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`detected_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`notification_id`) REFERENCES `youtube_notification`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `auto_room` (
|
||||
`guild_id` VARCHAR(32) NOT NULL,
|
||||
`voice_channel_id` VARCHAR(32) NOT NULL UNIQUE,
|
||||
`owner_id` VARCHAR(32) NOT NULL,
|
||||
`control_message_id` VARCHAR(32),
|
||||
`access_mode` VARCHAR(16) NOT NULL DEFAULT 'open',
|
||||
`whitelist` TEXT NOT NULL DEFAULT '[]',
|
||||
`blacklist` TEXT NOT NULL DEFAULT '[]',
|
||||
`managed_member_ids` TEXT NOT NULL DEFAULT '[]',
|
||||
PRIMARY KEY (`guild_id`, `voice_channel_id`)
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `patreon_post` (
|
||||
guid VARCHAR(512) PRIMARY KEY,
|
||||
title VARCHAR(512),
|
||||
link VARCHAR(1024),
|
||||
description TEXT,
|
||||
published_at VARCHAR(64),
|
||||
notified BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `mod_shoutbox_message` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`author` VARCHAR(64) NOT NULL,
|
||||
`message` VARCHAR(500) NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
+124
-115
@@ -3,41 +3,122 @@ import discord
|
||||
import logging
|
||||
import random
|
||||
|
||||
from webapp import webapp
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import Configuration, Humeur, Commande
|
||||
from discord import Message, TextChannel, Member
|
||||
from discord import Message, TextChannel, Member, VoiceChannel, app_commands
|
||||
from discordbot.humblebundle import checkHumbleBundleAndNotify
|
||||
from discordbot.freeloot import checkFreeLootAndNotify
|
||||
from discordbot.moderation import (
|
||||
handle_warning_command,
|
||||
handle_remove_warning_command,
|
||||
handle_list_warnings_command,
|
||||
handle_ban_command,
|
||||
handle_kick_command,
|
||||
handle_unban_command,
|
||||
handle_inspect_command,
|
||||
handle_ban_list_command,
|
||||
handle_staff_help_command,
|
||||
handle_timeout_command,
|
||||
handle_say_command
|
||||
handle_say_command,
|
||||
handle_transfer_command,
|
||||
transfer_message_context_menu,
|
||||
moderation_slash_ban,
|
||||
moderation_slash_kick,
|
||||
moderation_slash_timeout,
|
||||
moderation_ctx_ban_author,
|
||||
moderation_ctx_kick_author,
|
||||
moderation_ctx_timeout_author,
|
||||
moderation_slash_warn,
|
||||
moderation_slash_inspect,
|
||||
moderation_ctx_warn_author,
|
||||
moderation_slash_say,
|
||||
)
|
||||
from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
|
||||
from protondb import searhProtonDb
|
||||
from discordbot.rules_ack import assign_rules_arrival_on_join, register_persistent_rules_view
|
||||
from discordbot.patreon import checkPatreonPosts
|
||||
from discordbot.youtube import checkYouTubeVideos
|
||||
from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms, on_message_auto_rooms, cleanup_orphaned_auto_rooms, restore_auto_rooms
|
||||
from discordbot.protondb_discord import protondb_slash_command, pdb_slash_command
|
||||
|
||||
class DiscordBot(discord.Client):
|
||||
def __init__(self, *, intents: discord.Intents):
|
||||
super().__init__(intents=intents)
|
||||
self.tree = app_commands.CommandTree(self)
|
||||
self.synced = False
|
||||
self.background_tasks_started = False
|
||||
|
||||
async def setup_hook(self):
|
||||
for cmd in (
|
||||
transfer_message_context_menu,
|
||||
moderation_slash_ban,
|
||||
moderation_slash_kick,
|
||||
moderation_slash_timeout,
|
||||
moderation_ctx_ban_author,
|
||||
moderation_ctx_kick_author,
|
||||
moderation_ctx_timeout_author,
|
||||
moderation_slash_warn,
|
||||
moderation_slash_inspect,
|
||||
moderation_ctx_warn_author,
|
||||
moderation_slash_say,
|
||||
protondb_slash_command,
|
||||
pdb_slash_command,
|
||||
):
|
||||
self.tree.add_command(cmd)
|
||||
logging.info("Commandes d'application (transfert, modération, ProtonDB) ajoutées au CommandTree")
|
||||
register_persistent_rules_view(self)
|
||||
logging.info("Vue persistante règlement (bouton) enregistrée")
|
||||
|
||||
async def on_ready(self):
|
||||
logging.info(f'Connecté en tant que {self.user} (ID: {self.user.id})')
|
||||
webapp.config["BOT_STATUS"]["discord_connected"] = True
|
||||
webapp.config["BOT_STATUS"]["discord_guild_count"] = len(self.guilds)
|
||||
|
||||
if not self.synced:
|
||||
try:
|
||||
logging.info("Synchronisation des commandes d'application en cours...")
|
||||
|
||||
for guild in self.guilds:
|
||||
try:
|
||||
synced = await self.tree.sync(guild=guild)
|
||||
logging.info(f"✅ {len(synced)} commande(s) synchronisée(s) pour le serveur '{guild.name}' (ID: {guild.id})")
|
||||
except Exception as e:
|
||||
logging.error(f"❌ Erreur lors de la synchronisation pour {guild.name}: {e}")
|
||||
|
||||
synced_global = await self.tree.sync()
|
||||
logging.info(f"✅ {len(synced_global)} commande(s) synchronisée(s) globalement")
|
||||
|
||||
self.synced = True
|
||||
logging.info("🎉 Synchronisation complète terminée - Les commandes sont maintenant disponibles !")
|
||||
except Exception as e:
|
||||
logging.error(f"❌ Erreur lors de la synchronisation des commandes: {e}")
|
||||
|
||||
for c in self.get_all_channels() :
|
||||
logging.info(f'{c.id} {c.name}')
|
||||
|
||||
for guild in self.guilds:
|
||||
await updateInviteCache(guild)
|
||||
|
||||
await restore_auto_rooms(self)
|
||||
await cleanup_orphaned_auto_rooms(self)
|
||||
|
||||
# on_ready est rappelé après une reconnexion : ne pas démarrer plusieurs
|
||||
# boucles de surveillance, qui peuvent envoyer des notifications en double.
|
||||
if not self.background_tasks_started:
|
||||
self.background_tasks_started = True
|
||||
self.loop.create_task(self.updateStatus())
|
||||
self.loop.create_task(self.updateHumbleBundle())
|
||||
self.loop.create_task(self.updateYouTube())
|
||||
self.loop.create_task(self.updateFreeLoot())
|
||||
self.loop.create_task(self.updatePatreon())
|
||||
|
||||
async def on_disconnect(self):
|
||||
webapp.config["BOT_STATUS"]["discord_connected"] = False
|
||||
|
||||
async def updateStatus(self):
|
||||
while not self.is_closed():
|
||||
bot_status = webapp.config.get("BOT_STATUS", {})
|
||||
if bot_status.get("twitch_is_live") or bot_status.get("discord_streaming_activity"):
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
humeurs = Humeur.query.all()
|
||||
if len(humeurs)>0 :
|
||||
humeur = random.choice(humeurs)
|
||||
@@ -51,6 +132,21 @@ class DiscordBot(discord.Client):
|
||||
await checkHumbleBundleAndNotify(self)
|
||||
await asyncio.sleep(30*60)
|
||||
|
||||
async def updateYouTube(self):
|
||||
while not self.is_closed():
|
||||
await checkYouTubeVideos()
|
||||
await asyncio.sleep(5*60)
|
||||
|
||||
async def updateFreeLoot(self):
|
||||
while not self.is_closed():
|
||||
await checkFreeLootAndNotify(self)
|
||||
await asyncio.sleep(30*60)
|
||||
|
||||
async def updatePatreon(self):
|
||||
while not self.is_closed():
|
||||
await checkPatreonPosts(self)
|
||||
await asyncio.sleep(10*60)
|
||||
|
||||
def getAllTextChannel(self) -> list[TextChannel]:
|
||||
channels = []
|
||||
for channel in self.get_all_channels():
|
||||
@@ -58,6 +154,13 @@ class DiscordBot(discord.Client):
|
||||
channels.append(channel)
|
||||
return channels
|
||||
|
||||
def getAllVoiceChannels(self) -> list[VoiceChannel]:
|
||||
channels = []
|
||||
for channel in self.get_all_channels():
|
||||
if isinstance(channel, VoiceChannel):
|
||||
channels.append(channel)
|
||||
return channels
|
||||
|
||||
def getAllRoles(self):
|
||||
guilds_roles = []
|
||||
for guild in self.guilds:
|
||||
@@ -92,6 +195,10 @@ bot = DiscordBot(intents=intents)
|
||||
async def on_message(message: Message):
|
||||
if message.author == bot.user:
|
||||
return
|
||||
|
||||
# Gestion des messages dans les auto rooms (avant le check des commandes !)
|
||||
await on_message_auto_rooms(bot, message)
|
||||
|
||||
if not message.content.startswith('!'):
|
||||
return
|
||||
command_name = message.content.split()[0]
|
||||
@@ -101,10 +208,6 @@ async def on_message(message: Message):
|
||||
await handle_warning_command(message, bot)
|
||||
return
|
||||
|
||||
if command_name in ['!to', '!timeout']:
|
||||
await handle_timeout_command(message, bot)
|
||||
return
|
||||
|
||||
if command_name in ['!delaverto', '!removewarn', '!unwarn']:
|
||||
await handle_remove_warning_command(message, bot)
|
||||
return
|
||||
@@ -114,10 +217,6 @@ async def on_message(message: Message):
|
||||
return
|
||||
|
||||
if ConfigurationHelper().getValue('moderation_ban_enable'):
|
||||
if command_name == '!ban':
|
||||
await handle_ban_command(message, bot)
|
||||
return
|
||||
|
||||
if command_name == '!unban':
|
||||
await handle_unban_command(message, bot)
|
||||
return
|
||||
@@ -125,11 +224,6 @@ async def on_message(message: Message):
|
||||
await handle_ban_list_command(message, bot)
|
||||
return
|
||||
|
||||
if ConfigurationHelper().getValue('moderation_kick_enable'):
|
||||
if command_name == '!kick':
|
||||
await handle_kick_command(message, bot)
|
||||
return
|
||||
|
||||
if ConfigurationHelper().getValue('moderation_enable'):
|
||||
if command_name == '!inspect':
|
||||
await handle_inspect_command(message, bot)
|
||||
@@ -139,6 +233,10 @@ async def on_message(message: Message):
|
||||
await handle_say_command(message, bot)
|
||||
return
|
||||
|
||||
if command_name in ['!transfert', '!transfer', '!move']:
|
||||
await handle_transfer_command(message, bot)
|
||||
return
|
||||
|
||||
if command_name in ['!aide', '!help']:
|
||||
await handle_staff_help_command(message, bot)
|
||||
return
|
||||
@@ -151,105 +249,17 @@ async def on_message(message: Message):
|
||||
except Exception as e:
|
||||
logging.error(f'Échec de l\'exécution de la commande Discord : {e}')
|
||||
|
||||
if (ConfigurationHelper().getValue('proton_db_enable_enable') and (message.content.startswith('!protondb') or message.content.startswith('!pdb'))):
|
||||
if (message.content.find('<@')>0) :
|
||||
mention = message.content[message.content.find('<@'):]
|
||||
else :
|
||||
mention = message.author.mention
|
||||
name = message.content
|
||||
if name.startswith('!protondb'):
|
||||
name = name.replace('!protondb', '', 1)
|
||||
elif name.startswith('!pdb'):
|
||||
name = name.replace('!pdb', '', 1)
|
||||
name = name.replace(f'{mention}', '').strip();
|
||||
@bot.event
|
||||
async def on_voice_state_update(member: Member, before, after):
|
||||
await on_voice_state_update_auto_rooms(bot, member, before, after)
|
||||
|
||||
if not name or len(name) == 0:
|
||||
try:
|
||||
await message.delete()
|
||||
delete_time = ConfigurationHelper().getIntValue('proton_db_delete_time') or 10
|
||||
help_msg = await message.channel.send(
|
||||
f"{mention} ⚠️ Utilisation: `!pdb nom du jeu` ou `!protondb nom du jeu`\n"
|
||||
f"Exemple: `!pdb Elden Ring`",
|
||||
suppress_embeds=True
|
||||
)
|
||||
await asyncio.sleep(delete_time)
|
||||
await help_msg.delete()
|
||||
except Exception as e:
|
||||
logging.error(f"Échec de la gestion du message d'aide ProtonDB : {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
searching_msg = await message.channel.send(f"🔍 Recherche en cours pour **{name}**...")
|
||||
games = searhProtonDb(name)
|
||||
await searching_msg.delete()
|
||||
except:
|
||||
games = searhProtonDb(name)
|
||||
|
||||
if (len(games)==0) :
|
||||
msg = f'{mention} Je n\'ai pas trouvé de jeux correspondant à **{name}**. Es-tu sûr que le jeu est disponible sur Steam ?'
|
||||
try:
|
||||
await message.channel.send(msg, suppress_embeds=True)
|
||||
except Exception as e:
|
||||
logging.error(f"Échec de l'envoi du message ProtonDB : {e}")
|
||||
return
|
||||
total_games = len(games)
|
||||
tier_colors = {'platinum': '🟣', 'gold': '🟡', 'silver': '⚪', 'bronze': '🟤', 'borked': '🔴'}
|
||||
content = ""
|
||||
max_games = 15
|
||||
|
||||
for count, game in enumerate(games[:max_games]):
|
||||
g_name = str(game.get('name'))
|
||||
g_id = str(game.get('id'))
|
||||
tier = str(game.get('tier') or 'N/A').lower()
|
||||
tier_icon = tier_colors.get(tier, '⚫')
|
||||
|
||||
new_entry = f"**[{g_name}](<https://www.protondb.com/app/{g_id}>)**\n{tier_icon} Classé **{tier.capitalize()}**"
|
||||
|
||||
ac_status = game.get('anticheat_status')
|
||||
if ac_status:
|
||||
status_lower = str(ac_status).lower()
|
||||
ac_map = {
|
||||
'supported': ('✅', 'Supporté'),
|
||||
'running': ('⚠️', 'Fonctionne'),
|
||||
'broken': ('❌', 'Cassé'),
|
||||
'denied': ('🚫', 'Refusé'),
|
||||
'planned': ('📅', 'Planifié')
|
||||
}
|
||||
ac_emoji, ac_label = ac_map.get(status_lower, ('❔', str(ac_status)))
|
||||
acs = game.get('anticheats') or []
|
||||
ac_list = ', '.join([str(ac) for ac in acs if ac])
|
||||
new_entry += f" • [Anti-cheat {ac_emoji} {ac_label}"
|
||||
if ac_list:
|
||||
new_entry += f" ({ac_list})"
|
||||
new_entry += f"](<https://areweanticheatyet.com/game/{g_id}>)"
|
||||
|
||||
new_entry += "\n\n"
|
||||
|
||||
# Vérifier la limite avant d'ajouter
|
||||
if len(content) + len(new_entry) > 3900:
|
||||
rest = len(games) - count
|
||||
content += f"*... et {rest} autre{'s' if rest > 1 else ''} jeu{'x' if rest > 1 else ''}*"
|
||||
break
|
||||
|
||||
content += new_entry
|
||||
else:
|
||||
rest = max(0, len(games) - max_games)
|
||||
if rest > 0:
|
||||
content += f"*... et {rest} autre{'s' if rest > 1 else ''} jeu{'x' if rest > 1 else ''}*"
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"🎮 Résultats ProtonDB - **{total_games} jeu{'x' if total_games > 1 else ''} trouvé{'s' if total_games > 1 else ''}**",
|
||||
description=content,
|
||||
color=0x5865F2
|
||||
)
|
||||
|
||||
try :
|
||||
await message.channel.send(embed=embed)
|
||||
except Exception as e:
|
||||
logging.error(f"Échec de l'envoi de l'embed ProtonDB : {e}")
|
||||
@bot.event
|
||||
async def on_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 assign_rules_arrival_on_join(bot, member)
|
||||
await sendWelcomeMessage(bot, member)
|
||||
|
||||
@bot.event
|
||||
@@ -263,4 +273,3 @@ async def on_invite_create(invite):
|
||||
@bot.event
|
||||
async def on_invite_delete(invite):
|
||||
await updateInviteCache(invite.guild)
|
||||
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
# discordbot/auto_rooms.py — Auto rooms : message et réactions dans la partie texte du salon vocal (onglet Discussion)
|
||||
import logging
|
||||
import re
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
import discord
|
||||
from discord import Member, VoiceState
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import AutoRoom
|
||||
from webapp import webapp
|
||||
|
||||
# (guild_id, owner_id) -> room_data (voice_channel_id, control_message_id, whitelist, blacklist, access_mode)
|
||||
_rooms: dict[tuple[int, int], dict] = {}
|
||||
|
||||
# message_id -> (guild_id, owner_id) pour retrouver la room depuis une réaction
|
||||
_control_message_ids: dict[int, tuple[int, int]] = {}
|
||||
|
||||
# Emoji -> action
|
||||
REACTIONS = [
|
||||
("🔓", "open", "Ouvert"),
|
||||
("🔒", "closed", "Fermé"),
|
||||
("🔐", "private", "Privé"),
|
||||
("✅", "whitelist", "Liste blanche"),
|
||||
("🚫", "blacklist", "Liste noire"),
|
||||
("🧹", "purge", "Purge"),
|
||||
("👑", "transfer", "Propriété"),
|
||||
("🎤", "speak", "Micro"),
|
||||
("📹", "stream", "Vidéo"),
|
||||
("📊", "soundboards", "Soundboards"),
|
||||
("📝", "status", "Statut"),
|
||||
]
|
||||
|
||||
|
||||
def _status_display(access_mode: str) -> str:
|
||||
"""Cadenas ouvert ou fermé selon si le salon est ouvert ou pas."""
|
||||
if access_mode == "open":
|
||||
return "🔓 Ouvert"
|
||||
if access_mode == "closed":
|
||||
return "🔒 Fermé"
|
||||
if access_mode == "private":
|
||||
return "🔐 Privé"
|
||||
return "🔓 Ouvert"
|
||||
|
||||
|
||||
def _status_emoji(access_mode: str) -> str:
|
||||
"""Emoji cadenas seul pour le nom du channel."""
|
||||
if access_mode == "private":
|
||||
return "🔐"
|
||||
return "🔓" if access_mode == "open" else "🔒"
|
||||
|
||||
|
||||
def _build_control_embed(owner: Member, voice_channel: discord.VoiceChannel, access_mode: str, room: dict = None) -> discord.Embed:
|
||||
"""Construit l'embed de config avec infos du salon."""
|
||||
embed = discord.Embed(
|
||||
title="⚙️ Configuration du salon",
|
||||
description=(
|
||||
"Voici l'espace de configuration de votre salon vocal temporaire. "
|
||||
"Les différentes options disponibles vous permettent de personnaliser les permissions de votre salon selon vos préférences."
|
||||
),
|
||||
color=discord.Color.orange()
|
||||
)
|
||||
|
||||
# Récupération des infos
|
||||
whitelist = room.get("whitelist", set()) if room else set()
|
||||
blacklist = room.get("blacklist", set()) if room else set()
|
||||
whitelist_text = f"{len(whitelist)} membre(s)" if whitelist else "Aucun"
|
||||
blacklist_text = f"{len(blacklist)} membre(s)" if blacklist else "Aucun"
|
||||
|
||||
# Section Propriétaire
|
||||
embed.add_field(
|
||||
name=f"👤 Propriétaire du salon : {owner.display_name}",
|
||||
value="",
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Section Modes d'accès
|
||||
mode_open = "🔓 **Ouvert**\nLe salon sera ouvert à tous les membres, sauf ceux figurant sur la liste noire."
|
||||
mode_closed = "🔒 **Fermé**\nLe salon sera visible de tous, mais seulement accessible à la liste blanche."
|
||||
mode_private = "🔐 **Privé**\nLe salon ne sera visible et accessible qu'aux membres de la liste blanche."
|
||||
|
||||
embed.add_field(name=mode_open, value="", inline=True)
|
||||
embed.add_field(name=mode_closed, value="", inline=True)
|
||||
embed.add_field(name=mode_private, value="", inline=True)
|
||||
|
||||
# Section Listes
|
||||
embed.add_field(
|
||||
name="📝 **Liste blanche**",
|
||||
value=f"Les membres présents dans cette liste pourront toujours rejoindre le salon.\n\n{whitelist_text}",
|
||||
inline=True
|
||||
)
|
||||
embed.add_field(
|
||||
name="🚫 **Liste noire**",
|
||||
value=f"Les membres présents dans cette liste ne pourront jamais rejoindre le salon.\n\n{blacklist_text}",
|
||||
inline=True
|
||||
)
|
||||
embed.add_field(name="\u200b", value="", inline=True) # Spacer
|
||||
|
||||
# Section Purge
|
||||
embed.add_field(
|
||||
name="🧹 **Purge**",
|
||||
value="Déconnecter tous les membres du salon vocal à l'exception de ceux présents dans la liste blanche.",
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Section Transfert
|
||||
embed.add_field(
|
||||
name="👑 **Transférer**",
|
||||
value="Transférer la gestion du salon au membre de votre choix.",
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Note importante
|
||||
embed.add_field(
|
||||
name="💡",
|
||||
value="Les membres de la liste blanche ne sont pas impactés par les permissions refusées aux membres.",
|
||||
inline=False
|
||||
)
|
||||
|
||||
embed.set_footer(text="Réagissez avec les émojis ci-dessous pour configurer votre salon")
|
||||
return embed
|
||||
|
||||
|
||||
def _room_key(guild_id: int, owner_id: int) -> tuple[int, int]:
|
||||
return (guild_id, owner_id)
|
||||
|
||||
|
||||
def _get_room(guild_id: int, owner_id: int) -> Optional[dict]:
|
||||
room = _rooms.get(_room_key(guild_id, owner_id))
|
||||
if room:
|
||||
return room
|
||||
|
||||
# Le cache peut être perdu après un redémarrage ou une reconnexion : la base
|
||||
# reste la source de vérité pour éviter de créer une deuxième room au même membre.
|
||||
with webapp.app_context():
|
||||
record = AutoRoom.query.filter_by(guild_id=str(guild_id), owner_id=str(owner_id)).first()
|
||||
if not record:
|
||||
return None
|
||||
try:
|
||||
room = {
|
||||
"guild_id": guild_id,
|
||||
"voice_channel_id": int(record.voice_channel_id),
|
||||
"control_message_id": int(record.control_message_id) if record.control_message_id else None,
|
||||
"owner_id": owner_id,
|
||||
"whitelist": set(json.loads(record.whitelist or "[]")),
|
||||
"blacklist": set(json.loads(record.blacklist or "[]")),
|
||||
"managed_member_ids": set(json.loads(record.managed_member_ids or "[]")),
|
||||
"access_mode": record.access_mode or "open",
|
||||
}
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
_rooms[_room_key(guild_id, owner_id)] = room
|
||||
if room["control_message_id"]:
|
||||
_control_message_ids[room["control_message_id"]] = (guild_id, owner_id)
|
||||
return room
|
||||
|
||||
|
||||
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)
|
||||
_persist_room(guild_id, owner_id, data)
|
||||
|
||||
|
||||
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)
|
||||
if data:
|
||||
with webapp.app_context():
|
||||
AutoRoom.query.filter_by(guild_id=str(guild_id), voice_channel_id=str(data["voice_channel_id"])).delete()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _persist_room(guild_id: int, owner_id: int, data: dict):
|
||||
"""Sauvegarde l'état nécessaire à la reprise après redémarrage."""
|
||||
with webapp.app_context():
|
||||
record = AutoRoom.query.filter_by(
|
||||
guild_id=str(guild_id), voice_channel_id=str(data["voice_channel_id"])
|
||||
).first()
|
||||
if not record:
|
||||
record = AutoRoom(guild_id=str(guild_id), voice_channel_id=str(data["voice_channel_id"]))
|
||||
db.session.add(record)
|
||||
record.owner_id = str(owner_id)
|
||||
record.control_message_id = str(data["control_message_id"]) if data.get("control_message_id") else None
|
||||
record.access_mode = data.get("access_mode", "open")
|
||||
record.whitelist = json.dumps(sorted(data.get("whitelist", set())))
|
||||
record.blacklist = json.dumps(sorted(data.get("blacklist", set())))
|
||||
record.managed_member_ids = json.dumps(sorted(data.get("managed_member_ids", set())))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _auto_rooms_config() -> tuple[bool, int]:
|
||||
with webapp.app_context():
|
||||
config = ConfigurationHelper()
|
||||
return bool(config.getValue("auto_rooms_enable")), config.getIntValue("auto_rooms_channel_id")
|
||||
|
||||
|
||||
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)
|
||||
with webapp.app_context():
|
||||
record = AutoRoom.query.filter_by(guild_id=str(guild_id), voice_channel_id=str(channel_id)).first()
|
||||
if record:
|
||||
room = _get_room(guild_id, int(record.owner_id))
|
||||
if room:
|
||||
return (int(record.owner_id), room)
|
||||
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, room: dict):
|
||||
guild = channel.guild
|
||||
everyone = guild.default_role
|
||||
overwrites = dict(channel.overwrites) # Récupérer les overwrites existants
|
||||
|
||||
# Préserver les permissions existantes pour everyone (stream, speak, soundboards, etc.)
|
||||
existing_everyone_ow = overwrites.get(everyone, discord.PermissionOverwrite())
|
||||
everyone_ow = discord.PermissionOverwrite()
|
||||
|
||||
# Copier les permissions importantes qui ne doivent pas être écrasées
|
||||
everyone_ow.stream = existing_everyone_ow.stream
|
||||
everyone_ow.speak = existing_everyone_ow.speak
|
||||
everyone_ow.use_soundboard = existing_everyone_ow.use_soundboard
|
||||
|
||||
if mode == "open":
|
||||
everyone_ow.connect = True
|
||||
everyone_ow.view_channel = True
|
||||
# Ne toucher qu'aux overwrites créés par l'auto room, jamais aux droits
|
||||
# ajoutés manuellement par la modération.
|
||||
for target in list(overwrites.keys()):
|
||||
if target != everyone and isinstance(target, discord.Member):
|
||||
if target.id in room.get("managed_member_ids", set()) and target.id not in blacklist:
|
||||
overwrites.pop(target, None)
|
||||
# Ajouter les overwrites pour la blacklist
|
||||
for uid in blacklist:
|
||||
m = guild.get_member(uid)
|
||||
if m:
|
||||
overwrites[m] = discord.PermissionOverwrite(connect=False, view_channel=True)
|
||||
elif mode == "closed":
|
||||
everyone_ow.connect = False
|
||||
everyone_ow.view_channel = True
|
||||
for target in list(overwrites.keys()):
|
||||
if target != everyone and isinstance(target, discord.Member):
|
||||
if target.id in room.get("managed_member_ids", set()) and target.id not in whitelist:
|
||||
overwrites.pop(target, None)
|
||||
# Ajouter les overwrites pour la whitelist
|
||||
for uid in whitelist:
|
||||
m = guild.get_member(uid)
|
||||
if m:
|
||||
overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True)
|
||||
elif mode == "private":
|
||||
everyone_ow.connect = False
|
||||
everyone_ow.view_channel = False
|
||||
for target in list(overwrites.keys()):
|
||||
if target != everyone and isinstance(target, discord.Member):
|
||||
if target.id in room.get("managed_member_ids", set()) and target.id not in whitelist:
|
||||
overwrites.pop(target, None)
|
||||
# Ajouter les overwrites pour la whitelist
|
||||
for uid in whitelist:
|
||||
m = guild.get_member(uid)
|
||||
if m:
|
||||
overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True)
|
||||
|
||||
overwrites[everyone] = everyone_ow
|
||||
await channel.edit(overwrites=overwrites)
|
||||
room["managed_member_ids"] = set(blacklist if mode == "open" else whitelist)
|
||||
|
||||
|
||||
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()), room)
|
||||
_persist_room(guild_id, owner_id, room)
|
||||
# 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 **{_status_display(action)}**.")
|
||||
# Mettre à jour l'embed
|
||||
await _update_control_panel(bot, guild_id, owner_id, channel)
|
||||
|
||||
elif action == "whitelist":
|
||||
whitelist = room.get("whitelist", set())
|
||||
whitelist_text = ", ".join([f"<@{uid}>" for uid in whitelist]) if whitelist else "Aucun membre"
|
||||
await channel.send(
|
||||
f"**📝 Liste blanche actuelle :** {whitelist_text}\n\n"
|
||||
f"Mentionnez un membre pour l'ajouter ou le retirer de la liste blanche."
|
||||
)
|
||||
room["awaiting_whitelist"] = True
|
||||
|
||||
elif action == "blacklist":
|
||||
blacklist = room.get("blacklist", set())
|
||||
blacklist_text = ", ".join([f"<@{uid}>" for uid in blacklist]) if blacklist else "Aucun membre"
|
||||
await channel.send(
|
||||
f"**🚫 Liste noire actuelle :** {blacklist_text}\n\n"
|
||||
f"Mentionnez un membre pour l'ajouter ou le retirer de la liste noire."
|
||||
)
|
||||
room["awaiting_blacklist"] = True
|
||||
|
||||
elif action == "purge":
|
||||
whitelist = room.get("whitelist", set())
|
||||
kicked = 0
|
||||
for member in list(voice_channel.members):
|
||||
if member.id == owner_id or member.id in whitelist:
|
||||
continue
|
||||
try:
|
||||
await member.move_to(None)
|
||||
kicked += 1
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
await channel.send(f"🧹 Purge effectuée : {kicked} membre(s) déconnecté(s).")
|
||||
|
||||
elif action == "transfer":
|
||||
await channel.send(
|
||||
f"**👑 Transfert de propriété**\n\n"
|
||||
f"Mentionnez le membre à qui vous souhaitez transférer la gestion du salon."
|
||||
)
|
||||
room["awaiting_transfer"] = True
|
||||
|
||||
elif action in ("speak", "stream", "soundboards"):
|
||||
everyone = voice_channel.guild.default_role
|
||||
overwrites = dict(voice_channel.overwrites)
|
||||
ow = overwrites.get(everyone) or discord.PermissionOverwrite()
|
||||
|
||||
# BUG FIX : Par défaut, Discord autorise stream, speak et soundboards
|
||||
# Si la permission n'est pas explicitement définie (None), on considère qu'elle est True
|
||||
current = getattr(ow, action)
|
||||
if current is None:
|
||||
# Permission non définie = autorisée par défaut dans Discord
|
||||
# On veut la désactiver lors du premier clic
|
||||
setattr(ow, action, False)
|
||||
new_value = False
|
||||
else:
|
||||
# Permission définie, on l'inverse
|
||||
setattr(ow, action, not current)
|
||||
new_value = not current
|
||||
|
||||
overwrites[everyone] = ow
|
||||
await voice_channel.edit(overwrites=overwrites)
|
||||
|
||||
labels = {"speak": "Micro", "stream": "Vidéo/Partage d'écran", "soundboards": "Soundboards"}
|
||||
label = labels.get(action, action.capitalize())
|
||||
await channel.send(f"{label} : {'autorisé' if new_value else 'désactivé'} pour tous.")
|
||||
|
||||
elif action == "status":
|
||||
current_status = voice_channel.status or "Aucun statut défini"
|
||||
await channel.send(
|
||||
f"**Statut actuel du salon :** {current_status}\n\n"
|
||||
f"Pour modifier le statut du salon (le texte affiché en haut du salon vocal), "
|
||||
f"répondez avec le nouveau statut (max 500 caractères).\n"
|
||||
f"💡 Pour supprimer le statut, répondez avec `clear` ou `effacer`."
|
||||
)
|
||||
room["awaiting_status"] = True
|
||||
|
||||
|
||||
async def _update_control_panel(bot: discord.Client, guild_id: int, owner_id: int, channel):
|
||||
"""Met à jour le panneau de contrôle avec les nouvelles informations."""
|
||||
room = _get_room(guild_id, owner_id)
|
||||
if not room:
|
||||
return
|
||||
|
||||
control_message_id = room.get("control_message_id")
|
||||
if not control_message_id:
|
||||
return
|
||||
|
||||
voice_channel = bot.get_channel(room["voice_channel_id"])
|
||||
if not voice_channel or not isinstance(voice_channel, discord.VoiceChannel):
|
||||
return
|
||||
|
||||
owner = voice_channel.guild.get_member(owner_id)
|
||||
if not owner:
|
||||
return
|
||||
|
||||
try:
|
||||
msg = await channel.fetch_message(control_message_id)
|
||||
embed = _build_control_embed(owner, voice_channel, room.get("access_mode", "open"), room)
|
||||
await msg.edit(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
|
||||
async def send_control_panel(bot: discord.Client, guild_id: int, owner: Member, voice_channel: discord.VoiceChannel, room: dict) -> Optional[int]:
|
||||
"""Envoie le message de config avec réactions dans la partie texte du salon vocal (onglet Discussion). Seul le proprio peut réagir. Retourne l'id du message."""
|
||||
embed = _build_control_embed(owner, voice_channel, "open", room)
|
||||
|
||||
try:
|
||||
# Message dans la partie texte du vocal (onglet Discussion à droite)
|
||||
msg = await voice_channel.send(embed=embed)
|
||||
for emoji, _action, _label in REACTIONS:
|
||||
await msg.add_reaction(emoji)
|
||||
return msg.id
|
||||
except discord.HTTPException as e:
|
||||
logging.error(f"Impossible d'envoyer le panneau Auto Room dans le vocal : {e}")
|
||||
return None
|
||||
|
||||
|
||||
_AUTO_ROOM_NAME_PATTERN = re.compile(r"^Salon de .+ [🔓🔒🔐]$")
|
||||
|
||||
|
||||
async def cleanup_orphaned_auto_rooms(bot: discord.Client):
|
||||
"""Supprime les auto rooms orphelines (vides) au démarrage du bot."""
|
||||
enabled, trigger_channel_id = _auto_rooms_config()
|
||||
if not enabled:
|
||||
return
|
||||
if not trigger_channel_id:
|
||||
return
|
||||
|
||||
deleted = 0
|
||||
# Les rooms persistées sont connues même si leur nom a été modifié manuellement.
|
||||
for (guild_id, owner_id), room in list(_rooms.items()):
|
||||
channel = bot.get_channel(room["voice_channel_id"])
|
||||
if isinstance(channel, discord.VoiceChannel) and not channel.members:
|
||||
try:
|
||||
await channel.delete(reason="Nettoyage auto room vide au démarrage")
|
||||
_del_room(guild_id, owner_id)
|
||||
deleted += 1
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
for guild in bot.guilds:
|
||||
trigger_channel = guild.get_channel(trigger_channel_id)
|
||||
if not trigger_channel or not trigger_channel.category:
|
||||
continue
|
||||
category = trigger_channel.category
|
||||
for channel in list(category.voice_channels):
|
||||
if channel.id == trigger_channel_id:
|
||||
continue
|
||||
if not _AUTO_ROOM_NAME_PATTERN.match(channel.name):
|
||||
continue
|
||||
if len(channel.members) == 0:
|
||||
try:
|
||||
await channel.delete(reason="Nettoyage auto room orpheline au démarrage")
|
||||
result = _find_room_by_channel(guild.id, channel.id)
|
||||
if result:
|
||||
_del_room(guild.id, result[0])
|
||||
deleted += 1
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
if deleted > 0:
|
||||
logging.info(f"Nettoyage auto rooms : {deleted} salon(s) orphelin(s) supprimé(s)")
|
||||
|
||||
|
||||
async def restore_auto_rooms(bot: discord.Client):
|
||||
"""Recharge les salons encore existants après un redémarrage du bot."""
|
||||
with webapp.app_context():
|
||||
records = AutoRoom.query.all()
|
||||
for record in records:
|
||||
guild = bot.get_guild(int(record.guild_id))
|
||||
channel = guild.get_channel(int(record.voice_channel_id)) if guild else None
|
||||
if not isinstance(channel, discord.VoiceChannel):
|
||||
with webapp.app_context():
|
||||
db.session.delete(db.session.merge(record))
|
||||
db.session.commit()
|
||||
continue
|
||||
try:
|
||||
whitelist = set(json.loads(record.whitelist or "[]"))
|
||||
blacklist = set(json.loads(record.blacklist or "[]"))
|
||||
managed_member_ids = set(json.loads(record.managed_member_ids or "[]"))
|
||||
except (TypeError, ValueError):
|
||||
whitelist, blacklist, managed_member_ids = set(), set(), set()
|
||||
_set_room(int(record.guild_id), int(record.owner_id), {
|
||||
"guild_id": int(record.guild_id),
|
||||
"voice_channel_id": int(record.voice_channel_id),
|
||||
"control_message_id": int(record.control_message_id) if record.control_message_id else None,
|
||||
"owner_id": int(record.owner_id),
|
||||
"whitelist": whitelist,
|
||||
"blacklist": blacklist,
|
||||
"managed_member_ids": managed_member_ids,
|
||||
"access_mode": record.access_mode or "open",
|
||||
})
|
||||
|
||||
|
||||
async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member, before: VoiceState, after: VoiceState):
|
||||
enabled, trigger_channel_id = _auto_rooms_config()
|
||||
if not enabled:
|
||||
return
|
||||
if not trigger_channel_id:
|
||||
return
|
||||
|
||||
guild = member.guild
|
||||
|
||||
if after.channel and after.channel.id == trigger_channel_id:
|
||||
existing_room = _get_room(guild.id, member.id)
|
||||
if existing_room:
|
||||
old_channel = bot.get_channel(existing_room["voice_channel_id"])
|
||||
if old_channel and isinstance(old_channel, discord.VoiceChannel):
|
||||
# Ne jamais abandonner une room encore occupée : le propriétaire y retourne.
|
||||
await member.move_to(old_channel)
|
||||
return
|
||||
_del_room(guild.id, member.id)
|
||||
|
||||
category = after.channel.category
|
||||
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)
|
||||
|
||||
# Créer la room data d'abord
|
||||
room_data = {
|
||||
"guild_id": guild.id,
|
||||
"voice_channel_id": new_channel.id,
|
||||
"control_message_id": None, # Sera mis à jour après
|
||||
"owner_id": member.id,
|
||||
"whitelist": set(),
|
||||
"blacklist": set(),
|
||||
"managed_member_ids": set(),
|
||||
"access_mode": "open",
|
||||
}
|
||||
|
||||
control_message_id = await send_control_panel(bot, guild.id, member, new_channel, room_data)
|
||||
if not control_message_id:
|
||||
await new_channel.delete(reason="Panneau Auto Room impossible à créer")
|
||||
await member.move_to(after.channel)
|
||||
return
|
||||
room_data["control_message_id"] = control_message_id
|
||||
_set_room(guild.id, member.id, room_data)
|
||||
|
||||
logging.info(f"Auto room créé : {new_channel.name} pour {member.display_name}")
|
||||
except discord.HTTPException as e:
|
||||
logging.error(f"Erreur création auto room : {e}")
|
||||
|
||||
if before.channel and before.channel != after.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 and remaining:
|
||||
new_owner = remaining[0]
|
||||
_del_room(guild.id, owner_id)
|
||||
room["owner_id"] = new_owner.id
|
||||
_set_room(guild.id, new_owner.id, room)
|
||||
await before.channel.send(f"👑 {new_owner.mention} est maintenant propriétaire du salon.")
|
||||
await _update_control_panel(bot, guild.id, new_owner.id, before.channel)
|
||||
elif len(remaining) == 0:
|
||||
_del_room(guild.id, owner_id)
|
||||
try:
|
||||
await before.channel.delete(reason="Auto room vide")
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
|
||||
async def on_message_auto_rooms(bot: discord.Client, message: discord.Message):
|
||||
"""Gère les messages dans les salons vocaux pour les actions (statut, liste blanche/noire, etc.)."""
|
||||
if message.author.bot:
|
||||
return
|
||||
enabled, _ = _auto_rooms_config()
|
||||
if not enabled:
|
||||
return
|
||||
|
||||
# Vérifier si c'est dans un salon vocal (partie texte)
|
||||
if not isinstance(message.channel, discord.VoiceChannel):
|
||||
return
|
||||
|
||||
# Trouver si c'est une auto room
|
||||
result = _find_room_by_channel(message.guild.id, message.channel.id)
|
||||
if not result:
|
||||
return
|
||||
|
||||
owner_id, room = result
|
||||
|
||||
# Seul le propriétaire peut interagir
|
||||
if message.author.id != owner_id:
|
||||
return
|
||||
|
||||
voice_channel = message.channel
|
||||
|
||||
# Gestion du statut de salon
|
||||
if room.get("awaiting_status"):
|
||||
room["awaiting_status"] = False
|
||||
new_status = message.content.strip()
|
||||
|
||||
try:
|
||||
if new_status.lower() in ("clear", "effacer", "supprimer", "delete"):
|
||||
await voice_channel.edit(status=None)
|
||||
await message.channel.send("✅ Le statut du salon a été supprimé.")
|
||||
elif len(new_status) > 500:
|
||||
await message.channel.send("❌ Le statut ne peut pas dépasser 500 caractères.")
|
||||
room["awaiting_status"] = True # Réessayer
|
||||
else:
|
||||
await voice_channel.edit(status=new_status)
|
||||
await message.channel.send(f"✅ Le statut du salon a été mis à jour : **{new_status}**")
|
||||
except discord.HTTPException as e:
|
||||
await message.channel.send(f"❌ Erreur lors de la modification du statut : {e}")
|
||||
return
|
||||
|
||||
# Gestion de la liste blanche (si en attente)
|
||||
if room.get("awaiting_whitelist"):
|
||||
room["awaiting_whitelist"] = False
|
||||
if message.mentions:
|
||||
target = message.mentions[0]
|
||||
whitelist = room.get("whitelist", set())
|
||||
if target.id in whitelist:
|
||||
whitelist.remove(target.id)
|
||||
await message.channel.send(f"✅ {target.mention} a été retiré de la liste blanche.")
|
||||
else:
|
||||
whitelist.add(target.id)
|
||||
await message.channel.send(f"✅ {target.mention} a été ajouté à la liste blanche.")
|
||||
room["whitelist"] = whitelist
|
||||
blacklist = room.get("blacklist", set())
|
||||
blacklist.discard(target.id)
|
||||
room["blacklist"] = blacklist
|
||||
await _apply_access_mode(voice_channel, room.get("access_mode", "open"), whitelist, blacklist, room)
|
||||
_persist_room(message.guild.id, owner_id, room)
|
||||
await _update_control_panel(bot, message.guild.id, owner_id, message.channel)
|
||||
return
|
||||
|
||||
# Gestion de la liste noire (si en attente)
|
||||
if room.get("awaiting_blacklist"):
|
||||
room["awaiting_blacklist"] = False
|
||||
if message.mentions:
|
||||
target = message.mentions[0]
|
||||
blacklist = room.get("blacklist", set())
|
||||
if target.id in blacklist:
|
||||
blacklist.remove(target.id)
|
||||
await message.channel.send(f"✅ {target.mention} a été retiré de la liste noire.")
|
||||
else:
|
||||
blacklist.add(target.id)
|
||||
await message.channel.send(f"✅ {target.mention} a été ajouté à la liste noire.")
|
||||
room["blacklist"] = blacklist
|
||||
whitelist = room.get("whitelist", set())
|
||||
whitelist.discard(target.id)
|
||||
room["whitelist"] = whitelist
|
||||
await _apply_access_mode(voice_channel, room.get("access_mode", "open"), whitelist, blacklist, room)
|
||||
_persist_room(message.guild.id, owner_id, room)
|
||||
await _update_control_panel(bot, message.guild.id, owner_id, message.channel)
|
||||
return
|
||||
|
||||
# Gestion du transfert de propriété (si en attente)
|
||||
if room.get("awaiting_transfer"):
|
||||
room["awaiting_transfer"] = False
|
||||
if message.mentions:
|
||||
new_owner = message.mentions[0]
|
||||
if new_owner.id == owner_id:
|
||||
await message.channel.send("❌ Vous êtes déjà le propriétaire du salon.")
|
||||
return
|
||||
|
||||
# Transférer la propriété
|
||||
old_owner_id = owner_id
|
||||
_del_room(message.guild.id, old_owner_id)
|
||||
room["owner_id"] = new_owner.id
|
||||
_set_room(message.guild.id, new_owner.id, room)
|
||||
|
||||
# Renommer le salon
|
||||
try:
|
||||
base_name = f"Salon de {new_owner.display_name}"
|
||||
new_name = f"{base_name} {_status_emoji(room.get('access_mode', 'open'))}"
|
||||
await voice_channel.edit(name=new_name)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
await message.channel.send(f"✅ La propriété du salon a été transférée à {new_owner.mention}.")
|
||||
await _update_control_panel(bot, message.guild.id, new_owner.id, message.channel)
|
||||
return
|
||||
|
||||
|
||||
async def on_raw_reaction_add_auto_rooms(bot: discord.Client, payload: discord.RawReactionActionEvent):
|
||||
"""Seul le propriétaire peut réagir ; on retire la réaction des autres."""
|
||||
if payload.user_id == bot.user.id:
|
||||
return
|
||||
enabled, _ = _auto_rooms_config()
|
||||
if not enabled:
|
||||
return
|
||||
room_info = _find_room_by_message(payload.message_id)
|
||||
if not room_info:
|
||||
return
|
||||
guild_id, owner_id, room = room_info
|
||||
if payload.user_id != owner_id:
|
||||
try:
|
||||
channel = bot.get_channel(payload.channel_id)
|
||||
if channel:
|
||||
msg = await channel.fetch_message(payload.message_id)
|
||||
user = payload.member or await bot.fetch_user(payload.user_id)
|
||||
await msg.remove_reaction(payload.emoji, user)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
return
|
||||
|
||||
emoji_str = str(payload.emoji)
|
||||
action = None
|
||||
for e, a, _ in REACTIONS:
|
||||
if e == emoji_str:
|
||||
action = a
|
||||
break
|
||||
if not action:
|
||||
return
|
||||
|
||||
# Canal = salon vocal (le message est dans la partie texte du vocal)
|
||||
channel = bot.get_channel(payload.channel_id)
|
||||
if not channel or not hasattr(channel, "send"):
|
||||
return
|
||||
|
||||
await _handle_reaction_action(bot, guild_id, owner_id, action, channel)
|
||||
|
||||
try:
|
||||
msg = await channel.fetch_message(payload.message_id)
|
||||
user = payload.member or await bot.fetch_user(payload.user_id)
|
||||
await msg.remove_reaction(payload.emoji, user)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
@@ -0,0 +1,234 @@
|
||||
# FreeLoot Discord : notifications depuis les flux LootScraper (Epic, Amazon Prime, GOG, Steam, 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",
|
||||
"steam": "Steam",
|
||||
}
|
||||
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",
|
||||
"steam": "https://store.steampowered.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()
|
||||
@@ -33,17 +35,29 @@ def _findFirstNotNotified(bundles) :
|
||||
def _formatMessage(bundle):
|
||||
choice = bundle['choices'][0]
|
||||
date = datetime.datetime.fromtimestamp(bundle['endDate']/1000,datetime.UTC).strftime("%d %B %Y")
|
||||
message = f"@here **Humble Bundle** propose un pack de jeu [{bundle['name']}]({bundle['url']}) contenant :\n"
|
||||
message = f"**Humble Bundle** propose un pack de jeu [{bundle['name']}]({bundle['url']}) contenant :\n"
|
||||
for game in choice["games"]:
|
||||
message += f"- {game}\n"
|
||||
message += f"Pour {choice['price']}€, disponible jusqu'au {date}."
|
||||
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)
|
||||
|
||||
+1225
-354
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,282 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import requests
|
||||
from discord import Client
|
||||
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import PatreonPost
|
||||
from webapp import webapp
|
||||
|
||||
logger = logging.getLogger('patreon-notification')
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
_patreon_first_check = True
|
||||
|
||||
|
||||
def _get_mention_content() -> str:
|
||||
raw = ConfigurationHelper().getValue("patreon_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 _strip_html(html: str, max_len: int = 300) -> str:
|
||||
"""Extrait le texte brut depuis du HTML et tronque."""
|
||||
if not html:
|
||||
return ""
|
||||
text = re.sub(r'<br\s*/?>', '\n', html)
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = re.sub(r' ', ' ', text)
|
||||
text = re.sub(r'&', '&', text)
|
||||
text = re.sub(r'<', '<', text)
|
||||
text = re.sub(r'>', '>', text)
|
||||
text = re.sub(r'&#\d+;', '', text)
|
||||
text = re.sub(r'\n{3,}', '\n\n', text).strip()
|
||||
if len(text) > max_len:
|
||||
text = text[:max_len].rsplit(' ', 1)[0] + '...'
|
||||
return text
|
||||
|
||||
|
||||
def _extract_image(html: str) -> str | None:
|
||||
"""Extrait la première URL d'image depuis le contenu HTML."""
|
||||
if not html:
|
||||
return None
|
||||
match = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', html)
|
||||
if match:
|
||||
url = match.group(1)
|
||||
if url.startswith("http"):
|
||||
return url
|
||||
return None
|
||||
|
||||
|
||||
def _parse_item(item, creator_name: str) -> dict | None:
|
||||
"""Parse un <item> RSS et retourne un dict avec les métadonnées."""
|
||||
guid_elem = item.find('guid')
|
||||
if guid_elem is None or not guid_elem.text:
|
||||
return None
|
||||
title_elem = item.find('title')
|
||||
link_elem = item.find('link')
|
||||
desc_elem = item.find('description')
|
||||
pub_elem = item.find('pubDate')
|
||||
return {
|
||||
'guid': guid_elem.text.strip(),
|
||||
'title': title_elem.text if title_elem is not None else 'Nouveau post',
|
||||
'link': link_elem.text if link_elem is not None else '',
|
||||
'description': desc_elem.text if desc_elem is not None else '',
|
||||
'published_at': pub_elem.text if pub_elem is not None else '',
|
||||
'creator': creator_name,
|
||||
}
|
||||
|
||||
|
||||
def _fetch_rss() -> tuple[list[dict], str] | None:
|
||||
"""Fetch le RSS Patreon et retourne (posts, creator_name) ou None."""
|
||||
helper = ConfigurationHelper()
|
||||
creator = helper.getValue("patreon_creator")
|
||||
if not creator or not str(creator).strip():
|
||||
return None
|
||||
|
||||
rss_url = f"https://www.patreon.com/rss/{str(creator).strip()}"
|
||||
|
||||
try:
|
||||
response = requests.get(rss_url, timeout=15)
|
||||
except Exception as e:
|
||||
logger.error(f"Patreon: erreur réseau lors de la récupération du RSS: {e}")
|
||||
return None
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Patreon: HTTP {response.status_code} pour {rss_url}")
|
||||
return None
|
||||
|
||||
try:
|
||||
root = ET.fromstring(response.content)
|
||||
except ET.ParseError as e:
|
||||
logger.error(f"Patreon: erreur de parsing XML: {e}")
|
||||
return None
|
||||
|
||||
creator_name = creator
|
||||
channel_elem = root.find('.//channel/title')
|
||||
if channel_elem is not None and channel_elem.text:
|
||||
creator_name = channel_elem.text
|
||||
|
||||
items = root.findall('.//item')
|
||||
posts = []
|
||||
for item in items:
|
||||
parsed = _parse_item(item, creator_name)
|
||||
if parsed:
|
||||
posts.append(parsed)
|
||||
|
||||
return (posts, creator_name)
|
||||
|
||||
|
||||
def _build_embed(post: dict):
|
||||
import discord
|
||||
|
||||
title = post.get('title') or 'Nouveau post Patreon'
|
||||
link = post.get('link') or ''
|
||||
description = _strip_html(post.get('description') or '', max_len=350)
|
||||
creator = post.get('creator') or 'Patreon'
|
||||
image_url = _extract_image(post.get('description') or '')
|
||||
|
||||
helper = ConfigurationHelper()
|
||||
try:
|
||||
color = int(helper.getValue('patreon_embed_color') or 'F96854', 16)
|
||||
except (ValueError, TypeError):
|
||||
color = 0xF96854
|
||||
|
||||
embed = discord.Embed(
|
||||
title=title,
|
||||
url=link if link.startswith("http") else None,
|
||||
color=color,
|
||||
)
|
||||
|
||||
if description:
|
||||
embed.description = description
|
||||
|
||||
embed.set_author(
|
||||
name=creator,
|
||||
icon_url="https://c5.patreon.com/external/favicon/favicon-32x32.png",
|
||||
)
|
||||
|
||||
if image_url:
|
||||
embed.set_image(url=image_url)
|
||||
|
||||
embed.set_footer(text="MamieHenriette \u2022 Patreon")
|
||||
|
||||
return embed
|
||||
|
||||
|
||||
async def checkPatreonPosts(bot: Client):
|
||||
global _patreon_first_check
|
||||
with webapp.app_context():
|
||||
helper = ConfigurationHelper()
|
||||
if not helper.getValue("patreon_enable"):
|
||||
return
|
||||
|
||||
channel_id = helper.getIntValue("patreon_channel_id")
|
||||
if not channel_id:
|
||||
return
|
||||
|
||||
channel = bot.get_channel(channel_id)
|
||||
if not channel:
|
||||
logger.warning("Patreon: canal Discord introuvable")
|
||||
return
|
||||
|
||||
result = await asyncio.to_thread(_fetch_rss)
|
||||
if not result:
|
||||
return
|
||||
|
||||
posts, creator_name = result
|
||||
|
||||
if not posts:
|
||||
logger.info("Patreon: aucun post trouvé dans le flux RSS")
|
||||
return
|
||||
|
||||
if _patreon_first_check:
|
||||
logger.info("Patreon: première vérification, synchronisation sans notification")
|
||||
for post_data in posts:
|
||||
guid = post_data['guid']
|
||||
if not PatreonPost.query.get(guid):
|
||||
try:
|
||||
db.session.add(PatreonPost(
|
||||
guid=guid,
|
||||
title=post_data['title'],
|
||||
link=post_data['link'],
|
||||
description=post_data['description'],
|
||||
published_at=post_data['published_at'],
|
||||
notified=False,
|
||||
))
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Patreon: erreur de synchronisation pour {guid}: {e}")
|
||||
db.session.rollback()
|
||||
_patreon_first_check = False
|
||||
return
|
||||
|
||||
for post_data in posts:
|
||||
guid = post_data['guid']
|
||||
|
||||
if PatreonPost.query.get(guid):
|
||||
continue
|
||||
|
||||
try:
|
||||
embed = _build_embed(post_data)
|
||||
content = _get_mention_content()
|
||||
await channel.send(content=content or None, embed=embed)
|
||||
db.session.add(PatreonPost(
|
||||
guid=guid,
|
||||
title=post_data['title'],
|
||||
link=post_data['link'],
|
||||
description=post_data['description'],
|
||||
published_at=post_data['published_at'],
|
||||
notified=True,
|
||||
))
|
||||
db.session.commit()
|
||||
logger.info(f"Patreon: notification envoyée pour '{post_data['title']}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Patreon: envoi Discord échoué pour {guid}: {e}")
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
async def _send_post_to_discord_async(bot: Client, guid: str) -> tuple[bool, str]:
|
||||
"""Envoie un post Patreon sur Discord (appel manuel). Retourne (succès, message)."""
|
||||
helper = ConfigurationHelper()
|
||||
channel_id = helper.getIntValue("patreon_channel_id")
|
||||
if not channel_id:
|
||||
return (False, "Aucun canal Discord configuré pour Patreon.")
|
||||
channel = bot.get_channel(channel_id)
|
||||
if not channel:
|
||||
return (False, "Canal Discord introuvable.")
|
||||
|
||||
post_db = PatreonPost.query.get(guid)
|
||||
if not post_db:
|
||||
return (False, "Post introuvable en base de données.")
|
||||
|
||||
creator = helper.getValue("patreon_creator") or "Patreon"
|
||||
# Tenter de récupérer le nom du créateur depuis le RSS
|
||||
result = _fetch_rss()
|
||||
creator_name = result[1] if result else creator
|
||||
|
||||
post_data = {
|
||||
'title': post_db.title or 'Nouveau post',
|
||||
'link': post_db.link or '',
|
||||
'description': post_db.description or '',
|
||||
'creator': creator_name,
|
||||
}
|
||||
|
||||
try:
|
||||
embed = _build_embed(post_data)
|
||||
content = _get_mention_content()
|
||||
await channel.send(content=content or None, embed=embed)
|
||||
post_db.notified = True
|
||||
db.session.commit()
|
||||
return (True, "Notification envoyée sur Discord.")
|
||||
except Exception as e:
|
||||
logger.error(f"Patreon: envoi manuel échoué pour {guid}: {e}")
|
||||
db.session.rollback()
|
||||
return (False, str(e))
|
||||
|
||||
|
||||
def send_post_to_discord_sync(bot: Client, guid: str) -> tuple[bool, str]:
|
||||
"""Appel synchrone pour envoyer un post sur Discord (depuis la webapp)."""
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
_send_post_to_discord_async(bot, guid),
|
||||
bot.loop,
|
||||
)
|
||||
return future.result(timeout=15)
|
||||
except Exception as e:
|
||||
logger.error(f"Patreon: send_post_to_discord_sync: {e}")
|
||||
return (False, str(e))
|
||||
@@ -0,0 +1,123 @@
|
||||
import logging
|
||||
from typing import Any, List
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
from database.helpers import ConfigurationHelper
|
||||
from protondb import searhProtonDb
|
||||
|
||||
|
||||
def _build_protondb_embed(games: List[Any]) -> discord.Embed:
|
||||
total_games = len(games)
|
||||
tier_colors = {'platinum': '🟣', 'gold': '🟡', 'silver': '⚪', 'bronze': '🟤', 'borked': '🔴'}
|
||||
content = ""
|
||||
max_games = 15
|
||||
|
||||
for count, game in enumerate(games[:max_games]):
|
||||
g_name = str(game.get('name'))
|
||||
g_id = str(game.get('id'))
|
||||
tier = str(game.get('tier') or 'N/A').lower()
|
||||
tier_icon = tier_colors.get(tier, '⚫')
|
||||
|
||||
new_entry = f"**[{g_name}](<https://www.protondb.com/app/{g_id}>)**\n{tier_icon} Classé **{tier.capitalize()}**"
|
||||
|
||||
ac_status = game.get('anticheat_status')
|
||||
if ac_status:
|
||||
status_lower = str(ac_status).lower()
|
||||
ac_map = {
|
||||
'supported': ('✅', 'Supporté'),
|
||||
'running': ('⚠️', 'Fonctionne'),
|
||||
'broken': ('❌', 'Cassé'),
|
||||
'denied': ('🚫', 'Refusé'),
|
||||
'planned': ('📅', 'Planifié')
|
||||
}
|
||||
ac_emoji, ac_label = ac_map.get(status_lower, ('❔', str(ac_status)))
|
||||
acs = game.get('anticheats') or []
|
||||
ac_list = ', '.join([str(ac) for ac in acs if ac])
|
||||
new_entry += f" • [Anti-cheat {ac_emoji} {ac_label}"
|
||||
if ac_list:
|
||||
new_entry += f" ({ac_list})"
|
||||
new_entry += f"](<https://areweanticheatyet.com/game/{g_id}>)"
|
||||
|
||||
new_entry += "\n\n"
|
||||
|
||||
if len(content) + len(new_entry) > 3900:
|
||||
rest = len(games) - count
|
||||
content += f"*... et {rest} autre{'s' if rest > 1 else ''} jeu{'x' if rest > 1 else ''}*"
|
||||
break
|
||||
|
||||
content += new_entry
|
||||
else:
|
||||
rest = max(0, len(games) - max_games)
|
||||
if rest > 0:
|
||||
content += f"*... et {rest} autre{'s' if rest > 1 else ''} jeu{'x' if rest > 1 else ''}*"
|
||||
|
||||
return discord.Embed(
|
||||
title=f"🎮 Résultats ProtonDB - **{total_games} jeu{'x' if total_games > 1 else ''} trouvé{'s' if total_games > 1 else ''}**",
|
||||
description=content,
|
||||
color=0x5865F2
|
||||
)
|
||||
|
||||
|
||||
async def _protondb_search_followup(interaction: discord.Interaction, query: str) -> None:
|
||||
# Une seule réponse éditée (pas de followups en chaîne) : évite les fils « message introuvable »
|
||||
# et supprime le besoin d’un message séparé « Recherche en cours… ».
|
||||
await interaction.response.defer()
|
||||
try:
|
||||
games = searhProtonDb(query)
|
||||
except Exception as e:
|
||||
logging.error(f"ProtonDB : searhProtonDb : {e}")
|
||||
games = []
|
||||
|
||||
if len(games) == 0:
|
||||
try:
|
||||
await interaction.edit_original_response(
|
||||
content=(
|
||||
f"{interaction.user.mention} Je n'ai pas trouvé de jeux correspondant à **{query}**. "
|
||||
"Es-tu sûr que le jeu est disponible sur Steam ?"
|
||||
),
|
||||
embed=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"ProtonDB : edit_original_response (vide) : {e}")
|
||||
return
|
||||
|
||||
embed = _build_protondb_embed(games)
|
||||
try:
|
||||
await interaction.edit_original_response(content=None, embed=embed)
|
||||
except Exception as e:
|
||||
logging.error(f"ProtonDB : edit_original_response (embed) : {e}")
|
||||
try:
|
||||
await interaction.followup.send(embed=embed)
|
||||
except Exception as e2:
|
||||
logging.error(f"ProtonDB : followup de secours : {e2}")
|
||||
|
||||
|
||||
async def _protondb_slash_impl(interaction: discord.Interaction, jeu: str, exemple: str) -> None:
|
||||
if not ConfigurationHelper().getValue('proton_db_enable_enable'):
|
||||
await interaction.response.send_message(
|
||||
"❌ La commande ProtonDB n'est pas activée.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
query = jeu.strip()
|
||||
if not query:
|
||||
await interaction.response.send_message(
|
||||
f"⚠️ Indique le nom d'un jeu.\nExemple : `{exemple}`",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
await _protondb_search_followup(interaction, query)
|
||||
|
||||
|
||||
@app_commands.command(name="protondb", description="Recherche un jeu sur ProtonDB (compatibilité Linux / Steam).")
|
||||
@app_commands.describe(jeu="Nom du jeu (ex. Elden Ring)")
|
||||
async def protondb_slash_command(interaction: discord.Interaction, jeu: str):
|
||||
await _protondb_slash_impl(interaction, jeu, "/protondb jeu:Elden Ring")
|
||||
|
||||
|
||||
@app_commands.command(name="pdb", description="Alias de /protondb — recherche un jeu sur ProtonDB.")
|
||||
@app_commands.describe(jeu="Nom du jeu (ex. Elden Ring)")
|
||||
async def pdb_slash_command(interaction: discord.Interaction, jeu: str):
|
||||
await _protondb_slash_impl(interaction, jeu, "/pdb jeu:Elden Ring")
|
||||
@@ -0,0 +1,218 @@
|
||||
# Règlement Discord : rôle d'arrivée à la connexion, rôle validé au clic sur le bouton.
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import discord
|
||||
from discord import TextChannel
|
||||
from discord.ui import Button, View
|
||||
|
||||
from webapp import webapp
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
|
||||
RULES_BUTTON_CUSTOM_ID = "mamie_rules_accept"
|
||||
DEFAULT_BUTTON_LABEL = "J'ai lu le règlement"
|
||||
|
||||
|
||||
class AcceptRulesButton(Button):
|
||||
def __init__(self, label: str):
|
||||
super().__init__(
|
||||
style=discord.ButtonStyle.success,
|
||||
label=(label or DEFAULT_BUTTON_LABEL)[:80],
|
||||
custom_id=RULES_BUTTON_CUSTOM_ID,
|
||||
)
|
||||
|
||||
async def callback(self, interaction: discord.Interaction):
|
||||
await handle_rules_accept(interaction)
|
||||
|
||||
|
||||
class RulesAcceptView(View):
|
||||
def __init__(self, button_label: str):
|
||||
super().__init__(timeout=None)
|
||||
self.add_item(AcceptRulesButton(button_label))
|
||||
|
||||
|
||||
def register_persistent_rules_view(client: discord.Client) -> None:
|
||||
with webapp.app_context():
|
||||
label = (ConfigurationHelper().getValue("rules_button_label") or "").strip() or DEFAULT_BUTTON_LABEL
|
||||
client.add_view(RulesAcceptView(label))
|
||||
|
||||
|
||||
def _rules_ack_button_success_text(
|
||||
validated_role: discord.Role,
|
||||
presentation_ch: TextChannel | None,
|
||||
) -> str:
|
||||
base = f"c'est bon 😌 tu as maintenant le rôle **{validated_role.name}**."
|
||||
if presentation_ch:
|
||||
return f"{base} Tu peux aller te présenter dans {presentation_ch.mention}."
|
||||
return base
|
||||
|
||||
|
||||
async def handle_rules_accept(interaction: discord.Interaction) -> None:
|
||||
if not interaction.guild:
|
||||
await interaction.response.send_message("Action impossible dans ce contexte.", ephemeral=True)
|
||||
return
|
||||
|
||||
try:
|
||||
member = await interaction.guild.fetch_member(interaction.user.id)
|
||||
except (discord.NotFound, discord.HTTPException):
|
||||
member = interaction.user if isinstance(interaction.user, discord.Member) else None
|
||||
if member is None:
|
||||
await interaction.response.send_message("Action impossible dans ce contexte.", ephemeral=True)
|
||||
return
|
||||
|
||||
with webapp.app_context():
|
||||
config = ConfigurationHelper()
|
||||
enabled = config.getValue("rules_ack_enable")
|
||||
arrival_id = config.getIntValue("rules_arrival_role_id")
|
||||
presentation_id = config.getIntValue("rules_presentation_channel_id")
|
||||
validated_id = config.getIntValue("rules_validated_role_id")
|
||||
|
||||
if not enabled:
|
||||
await interaction.response.send_message("Cette fonctionnalité est désactivée.", ephemeral=True)
|
||||
return
|
||||
|
||||
if not validated_id:
|
||||
await interaction.response.send_message("Rôle membre validé non configuré.", ephemeral=True)
|
||||
return
|
||||
|
||||
validated_role = interaction.guild.get_role(validated_id)
|
||||
if not validated_role:
|
||||
await interaction.response.send_message("Rôle membre validé introuvable sur ce serveur.", ephemeral=True)
|
||||
return
|
||||
|
||||
arrival_role = interaction.guild.get_role(arrival_id) if arrival_id else None
|
||||
|
||||
presentation_ch = interaction.guild.get_channel(presentation_id)
|
||||
presentation_ch = presentation_ch if isinstance(presentation_ch, TextChannel) else None
|
||||
success_text = _rules_ack_button_success_text(validated_role, presentation_ch)
|
||||
|
||||
# Le retrait du rôle d'arrivée est la marque persistante de l'acceptation.
|
||||
# Le rôle validé peut ensuite être remplacé par le système de présentation ;
|
||||
# dans ce cas, un nouveau clic ne doit surtout pas rejouer l'attribution.
|
||||
if arrival_role and arrival_role not in member.roles:
|
||||
await interaction.response.send_message(
|
||||
"Tu as déjà accepté le règlement. Tes rôles ne seront pas modifiés.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
if validated_role in member.roles:
|
||||
await interaction.response.send_message(success_text, ephemeral=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await member.add_roles(validated_role, reason="Acceptation du règlement (bouton)")
|
||||
if arrival_role and arrival_role in member.roles:
|
||||
await member.remove_roles(arrival_role, reason="Passage membre validé après charte")
|
||||
except discord.Forbidden:
|
||||
await interaction.response.send_message(
|
||||
"Je n'ai pas la permission de modifier tes rôles (rôle du bot trop bas ou « Gérer les rôles » manquant).",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
except discord.HTTPException as e:
|
||||
await interaction.response.send_message(f"Erreur Discord : {e}", ephemeral=True)
|
||||
return
|
||||
|
||||
await interaction.response.send_message(success_text, ephemeral=True)
|
||||
|
||||
|
||||
async def publish_rules_embed(bot: discord.Client) -> tuple[bool, str]:
|
||||
with webapp.app_context():
|
||||
config = ConfigurationHelper()
|
||||
if not config.getValue("rules_ack_enable"):
|
||||
return False, "Activez d'abord « Règlement avec bouton » et enregistrez la configuration."
|
||||
|
||||
channel_id = config.getIntValue("rules_channel_id")
|
||||
body = (config.getValue("rules_embed_body") or "").strip()
|
||||
title = (config.getValue("rules_embed_title") or "").strip() or "Bienvenue"
|
||||
button_label = (config.getValue("rules_button_label") or "").strip() or DEFAULT_BUTTON_LABEL
|
||||
old_mid = config.getIntValue("rules_message_id")
|
||||
old_ch_id = config.getIntValue("rules_message_channel_id")
|
||||
|
||||
if not channel_id:
|
||||
return False, "Choisissez un canal du règlement."
|
||||
if not body:
|
||||
return False, "Le texte du règlement est vide."
|
||||
|
||||
channel = bot.get_channel(channel_id)
|
||||
if not channel or not isinstance(channel, TextChannel):
|
||||
return False, "Canal du règlement introuvable."
|
||||
|
||||
if len(body) > 4096:
|
||||
body = body[:4093] + "..."
|
||||
|
||||
embed = discord.Embed(title=title, description=body, color=discord.Color.blurple())
|
||||
view = RulesAcceptView(button_label)
|
||||
|
||||
try:
|
||||
if old_mid and old_ch_id:
|
||||
old_ch = bot.get_channel(old_ch_id)
|
||||
if old_ch and isinstance(old_ch, TextChannel):
|
||||
try:
|
||||
old_msg = await old_ch.fetch_message(old_mid)
|
||||
await old_msg.delete()
|
||||
except (discord.NotFound, discord.Forbidden, discord.HTTPException):
|
||||
pass
|
||||
|
||||
msg = await channel.send(embed=embed, view=view)
|
||||
|
||||
with webapp.app_context():
|
||||
ConfigurationHelper().createOrUpdate("rules_message_id", str(msg.id))
|
||||
ConfigurationHelper().createOrUpdate("rules_message_channel_id", str(channel.id))
|
||||
db.session.commit()
|
||||
|
||||
return True, "Message du règlement publié sur Discord."
|
||||
except discord.Forbidden:
|
||||
return False, "Permission refusée pour envoyer ou supprimer un message dans ce canal."
|
||||
except Exception as e:
|
||||
logging.exception("publish_rules_embed")
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def publish_rules_embed_sync(bot: discord.Client) -> tuple[bool, str]:
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(publish_rules_embed(bot), bot.loop)
|
||||
return future.result(timeout=30)
|
||||
except Exception as e:
|
||||
logging.exception("publish_rules_embed_sync")
|
||||
return False, str(e)
|
||||
|
||||
|
||||
async def assign_rules_arrival_on_join(bot: discord.Client, member: discord.Member) -> None:
|
||||
"""Attribue uniquement le rôle d'arrivée à la connexion (le rôle validé vient du bouton)."""
|
||||
with webapp.app_context():
|
||||
config = ConfigurationHelper()
|
||||
if not config.getValue("rules_ack_enable"):
|
||||
return
|
||||
arrival_id = config.getIntValue("rules_arrival_role_id")
|
||||
validated_id = config.getIntValue("rules_validated_role_id")
|
||||
|
||||
if not arrival_id:
|
||||
return
|
||||
|
||||
guild = member.guild
|
||||
arrival_role = guild.get_role(arrival_id)
|
||||
if not arrival_role:
|
||||
logging.warning("assign_rules_arrival_on_join: rôle d'arrivée %s introuvable sur %s", arrival_id, guild.id)
|
||||
return
|
||||
|
||||
if validated_id:
|
||||
validated_role = guild.get_role(validated_id)
|
||||
if validated_role and validated_role in member.roles:
|
||||
return
|
||||
|
||||
if arrival_role in member.roles:
|
||||
return
|
||||
|
||||
try:
|
||||
await member.add_roles(arrival_role, reason="Règlement : rôle d'arrivée à la connexion")
|
||||
except discord.Forbidden:
|
||||
logging.warning(
|
||||
"assign_rules_arrival_on_join: permission refusée pour %s sur %s (hiérarchie des rôles ?)",
|
||||
member.id,
|
||||
guild.id,
|
||||
)
|
||||
except discord.HTTPException as e:
|
||||
logging.warning("assign_rules_arrival_on_join: %s", e)
|
||||
@@ -100,8 +100,14 @@ async def sendWelcomeMessage(bot: discord.Client, member: Member):
|
||||
embed.set_footer(text=f'ID: {member.id}')
|
||||
|
||||
try:
|
||||
await channel.send(embed=embed)
|
||||
message = await channel.send(embed=embed)
|
||||
logging.info(f'Message de bienvenue envoyé pour {member.name}')
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
account_age = (now - member.created_at).days
|
||||
if account_age < 7:
|
||||
await message.add_reaction('⚠️')
|
||||
logging.info(f'Réaction warning ajoutée pour {member.name} (compte créé il y a {account_age} jours)')
|
||||
except Exception as e:
|
||||
logging.error(f'Échec de l\'envoi du message de bienvenue : {e}')
|
||||
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import logging
|
||||
import asyncio
|
||||
import xml.etree.ElementTree as ET
|
||||
import requests
|
||||
import discord
|
||||
|
||||
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}")
|
||||
db.session.rollback()
|
||||
continue
|
||||
|
||||
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}")
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
def _extract_embed_config(notification: YouTubeNotification) -> dict:
|
||||
"""Extrait toutes les valeurs ORM nécessaires à l'envoi dans un dict plain Python.
|
||||
Doit être appelé pendant que le contexte Flask est actif."""
|
||||
return {
|
||||
'notify_channel': notification.notify_channel,
|
||||
'message_template': notification.message or '',
|
||||
'embed_title': notification.embed_title,
|
||||
'embed_description': notification.embed_description,
|
||||
'embed_color': notification.embed_color or 'FF0000',
|
||||
'embed_footer': notification.embed_footer,
|
||||
'embed_author_name': notification.embed_author_name,
|
||||
'embed_author_icon': (notification.embed_author_icon or '').strip(),
|
||||
'embed_thumbnail': bool(notification.embed_thumbnail),
|
||||
'embed_image': bool(notification.embed_image),
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
video_data = {
|
||||
'title': video_title,
|
||||
'url': video_url,
|
||||
'published': published_at,
|
||||
'channel_name': channel_name,
|
||||
'thumbnail': thumbnail,
|
||||
'is_short': is_short
|
||||
}
|
||||
|
||||
if notification.video_type == 'all':
|
||||
videos.append((video_id, video_data))
|
||||
elif notification.video_type == 'short' and is_short:
|
||||
videos.append((video_id, video_data))
|
||||
elif notification.video_type == 'video' and not is_short:
|
||||
videos.append((video_id, video_data))
|
||||
|
||||
videos.sort(key=lambda x: x[1]['published'], reverse=True)
|
||||
|
||||
# Enregistrer toutes les vidéos du flux dans l'historique (les doublons sont ignorés)
|
||||
for vid, vdata in videos:
|
||||
_save_video_history(notification.id, vid, vdata, notified=False)
|
||||
|
||||
if not videos:
|
||||
return
|
||||
|
||||
latest_video_id, _ = videos[0]
|
||||
if is_first_check or not notification.last_video_id:
|
||||
# Au démarrage, on initialise le curseur sans annoncer l'historique.
|
||||
notification.last_video_id = latest_video_id
|
||||
db.session.commit()
|
||||
logger.info(f"YouTube: synchronisation initiale pour {channel_id}, dernière vidéo: {latest_video_id}")
|
||||
return
|
||||
|
||||
if latest_video_id == notification.last_video_id:
|
||||
return
|
||||
|
||||
# Une chaîne peut publier plusieurs vidéos entre deux contrôles : le choix
|
||||
# fonctionnel est d'annoncer uniquement la plus récente, jamais l'historique.
|
||||
logger.info(f"Nouvelle vidéo détectée: {latest_video_id} pour la chaîne {channel_id}")
|
||||
embed_config = _extract_embed_config(notification)
|
||||
success = await _notifyVideo(embed_config, videos[0][1], latest_video_id)
|
||||
if not success:
|
||||
# Ne pas avancer le curseur : cette dernière vidéo sera réessayée au prochain cycle.
|
||||
logger.warning(f"Notification échouée pour {latest_video_id}; nouvel essai au prochain contrôle")
|
||||
return
|
||||
|
||||
_save_video_history(notification.id, latest_video_id, videos[0][1], notified=True)
|
||||
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}")
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
def _save_video_history(notification_id: int, video_id: str, video_data: dict, notified: bool):
|
||||
"""Enregistre une vidéo dans l'historique (ne fait rien si déjà présente)."""
|
||||
from database.models import YouTubeVideoHistory
|
||||
try:
|
||||
existing = YouTubeVideoHistory.query.filter_by(
|
||||
notification_id=notification_id, video_id=video_id
|
||||
).first()
|
||||
if existing:
|
||||
if notified and not existing.notified:
|
||||
existing.notified = True
|
||||
db.session.commit()
|
||||
return
|
||||
entry = YouTubeVideoHistory(
|
||||
notification_id=notification_id,
|
||||
video_id=video_id,
|
||||
title=video_data.get('title', 'Sans titre'),
|
||||
url=video_data.get('url', f"https://www.youtube.com/watch?v={video_id}"),
|
||||
channel_name=video_data.get('channel_name', 'Inconnu'),
|
||||
thumbnail=video_data.get('thumbnail'),
|
||||
published_at=video_data.get('published', ''),
|
||||
is_short=video_data.get('is_short', False),
|
||||
notified=notified,
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de l'enregistrement de l'historique vidéo: {e}")
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
async def _notifyVideo(embed_config: dict, video_data: dict, video_id: str) -> bool:
|
||||
"""Envoie la notification Discord. Retourne True si l'envoi a réussi."""
|
||||
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)
|
||||
|
||||
message_template = embed_config.get('message_template', '')
|
||||
try:
|
||||
message = message_template.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, AttributeError, ValueError) as e:
|
||||
logger.error(f"Erreur de formatage du message: {e}")
|
||||
message = f"🎥 Nouvelle vidéo de {channel_name}: [{video_title}]({video_url})"
|
||||
|
||||
logger.info(f"Envoi de notification YouTube: {message}")
|
||||
return await _sendMessage(embed_config, 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}")
|
||||
return False
|
||||
|
||||
|
||||
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(embed_config: dict, message: str, video_url: str, thumbnail: str, video_title: str, channel_name: str, video_id: str, published_at: str, is_short: bool) -> bool:
|
||||
"""Envoie le message Discord. Retourne True si l'envoi a réussi."""
|
||||
from discordbot import bot
|
||||
try:
|
||||
channel_id = int(embed_config['notify_channel'])
|
||||
discord_channel = bot.get_channel(channel_id)
|
||||
if not discord_channel:
|
||||
# Le salon peut ne pas être présent dans le cache local après une reconnexion.
|
||||
discord_channel = await bot.fetch_channel(channel_id)
|
||||
if not discord_channel:
|
||||
logger.error(f"Canal Discord {channel_id} introuvable")
|
||||
return False
|
||||
|
||||
embed_title_text = _format_embed_text(embed_config['embed_title'], channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if embed_config['embed_title'] else video_title
|
||||
embed_description = _format_embed_text(embed_config['embed_description'], channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if embed_config['embed_description'] else None
|
||||
|
||||
try:
|
||||
embed_color = int(embed_config['embed_color'], 16)
|
||||
except ValueError:
|
||||
embed_color = 0xFF0000
|
||||
|
||||
embed = discord.Embed(
|
||||
title=embed_title_text,
|
||||
url=video_url,
|
||||
color=embed_color
|
||||
)
|
||||
|
||||
if embed_description:
|
||||
embed.description = embed_description
|
||||
|
||||
author_name = _format_embed_text(embed_config['embed_author_name'], channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if embed_config['embed_author_name'] else channel_name
|
||||
author_icon_raw = embed_config['embed_author_icon']
|
||||
author_icon = author_icon_raw if author_icon_raw.startswith(("http://", "https://")) else "https://www.youtube.com/img/desktop/yt_1200.png"
|
||||
embed.set_author(name=author_name, icon_url=author_icon)
|
||||
|
||||
if embed_config['embed_thumbnail'] and thumbnail:
|
||||
embed.set_thumbnail(url=thumbnail)
|
||||
|
||||
if embed_config['embed_image'] and thumbnail:
|
||||
embed.set_image(url=thumbnail)
|
||||
|
||||
if embed_config['embed_footer']:
|
||||
footer_text = _format_embed_text(embed_config['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")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de l'envoi du message Discord: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _send_video_notification_async(history_id: int) -> tuple[bool, str]:
|
||||
"""Force l'envoi d'une notification pour une vidéo de l'historique. Retourne (succès, message)."""
|
||||
from database.models import YouTubeVideoHistory
|
||||
with webapp.app_context():
|
||||
history = YouTubeVideoHistory.query.get(history_id)
|
||||
if not history:
|
||||
return (False, "Vidéo introuvable dans l'historique.")
|
||||
|
||||
notification = YouTubeNotification.query.get(history.notification_id)
|
||||
if not notification:
|
||||
return (False, "Notification YouTube associée introuvable.")
|
||||
|
||||
embed_config = _extract_embed_config(notification)
|
||||
video_data = {
|
||||
'title': history.title or 'Sans titre',
|
||||
'url': history.url or f"https://www.youtube.com/watch?v={history.video_id}",
|
||||
'channel_name': history.channel_name or 'Inconnu',
|
||||
'thumbnail': history.thumbnail or '',
|
||||
'published': history.published_at or '',
|
||||
'is_short': history.is_short,
|
||||
}
|
||||
|
||||
success = await _notifyVideo(embed_config, video_data, history.video_id)
|
||||
if success:
|
||||
history.notified = True
|
||||
db.session.commit()
|
||||
return (True, "Notification envoyée sur Discord.")
|
||||
else:
|
||||
db.session.rollback()
|
||||
return (False, "Échec de l'envoi sur Discord.")
|
||||
|
||||
|
||||
def send_video_notification_sync(history_id: int) -> tuple[bool, str]:
|
||||
"""Appel synchrone pour forcer une notification (depuis la webapp)."""
|
||||
from discordbot import bot
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
_send_video_notification_async(history_id),
|
||||
bot.loop,
|
||||
)
|
||||
return future.result(timeout=15)
|
||||
except Exception as e:
|
||||
logger.error(f"send_video_notification_sync: {e}")
|
||||
return (False, str(e))
|
||||
@@ -0,0 +1,214 @@
|
||||
# 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"
|
||||
STEAM_FEED_URL = "https://feed.eikowagenknecht.com/lootscraper_steam_game.xml"
|
||||
FEED_URLS = (FEED_URL, STEAM_FEED_URL)
|
||||
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", "🍎"),
|
||||
("steam", "Steam", "🎮"),
|
||||
]
|
||||
|
||||
|
||||
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"
|
||||
if "STEAM" in title_upper or "store.steampowered.com" in link_lower:
|
||||
return "steam"
|
||||
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) - ",
|
||||
"Steam (Game, PC) - ",
|
||||
):
|
||||
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_single_feed(feed_url: str) -> list[dict]:
|
||||
"""Récupère et parse un flux Atom LootScraper."""
|
||||
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 []
|
||||
|
||||
|
||||
def fetch_feed() -> list[dict] | None:
|
||||
"""Récupère les flux LootScraper général et Steam."""
|
||||
entries = []
|
||||
for feed_url in FEED_URLS:
|
||||
entries.extend(_fetch_single_feed(feed_url))
|
||||
return entries or None
|
||||
|
||||
|
||||
def get_display_entries() -> list[dict]:
|
||||
"""Retourne la liste des entrées formatées pour affichage (webapp)."""
|
||||
raw = fetch_feed()
|
||||
if not raw:
|
||||
return []
|
||||
result = []
|
||||
for e in raw:
|
||||
sk = source_key_from_entry(e["title"], e["link"])
|
||||
content = e.get("content") or ""
|
||||
desc = extract_description_from_content(content)
|
||||
result.append({
|
||||
"id": e["id"],
|
||||
"title": e["title"],
|
||||
"link": e["link"],
|
||||
"game_name": game_name_from_title(e["title"]),
|
||||
"source_key": sk or "other",
|
||||
"source_label": next((s[1] for s in SOURCES if s[0] == sk), sk or "Autre"),
|
||||
"emoji": next((s[2] for s in SOURCES if s[0] == sk), "🎁"),
|
||||
"image_url": extract_image_from_content(content),
|
||||
"description": desc,
|
||||
"valid_to": extract_valid_to_from_content(content),
|
||||
"recommended_price": extract_recommended_price_from_content(content),
|
||||
"genres": extract_genres_from_content(content),
|
||||
"rating": extract_rating_from_content(content),
|
||||
"updated": e.get("updated"),
|
||||
})
|
||||
return result
|
||||
@@ -11,6 +11,7 @@ twitchAPI>=4.5.0
|
||||
|
||||
# Nécessaire pour l'hébergement du site web
|
||||
flask>=2.3.2
|
||||
flask-login>=0.6.3
|
||||
flask-sqlalchemy>=3.0.3
|
||||
flask[async]
|
||||
waitress>=3.0.2
|
||||
|
||||
+300
-19
@@ -1,72 +1,353 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from twitchAPI.twitch import Twitch
|
||||
from twitchAPI.type import AuthScope, ChatEvent
|
||||
from twitchAPI.chat import Chat, ChatEvent, ChatMessage, EventData
|
||||
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import Commande
|
||||
|
||||
|
||||
def _user_has_twitch_permission(msg: ChatMessage, required: str) -> bool:
|
||||
"""Vérifie si l'utilisateur a le niveau de permission requis (viewer, sub, vip, moderator)."""
|
||||
if not required or required == 'viewer':
|
||||
return True
|
||||
is_broadcaster = msg.user.name.lower() == msg.room.name.lower()
|
||||
is_mod = msg.user.mod or is_broadcaster
|
||||
if required == 'moderator':
|
||||
return is_mod
|
||||
if required == 'vip':
|
||||
return msg.user.vip or is_mod
|
||||
if required == 'sub':
|
||||
return msg.user.subscriber or msg.user.vip or is_mod
|
||||
return True
|
||||
from twitchbot.live_alert import checkOnlineStreamer
|
||||
from twitchbot.announcements import checkAndSendAnnouncements, incrementMessageCount
|
||||
from twitchbot import moderation
|
||||
from twitchbot import link_filter
|
||||
from twitchbot import event_notifications
|
||||
from webapp import webapp
|
||||
|
||||
USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT]
|
||||
USER_SCOPE = [
|
||||
AuthScope.CHAT_READ,
|
||||
AuthScope.CHAT_EDIT,
|
||||
AuthScope.MODERATOR_MANAGE_BANNED_USERS,
|
||||
AuthScope.MODERATOR_MANAGE_CHAT_MESSAGES,
|
||||
AuthScope.MODERATOR_MANAGE_CHAT_SETTINGS,
|
||||
AuthScope.MODERATOR_MANAGE_SHIELD_MODE,
|
||||
AuthScope.CHANNEL_MANAGE_BROADCAST,
|
||||
AuthScope.MODERATOR_READ_FOLLOWERS,
|
||||
AuthScope.CHANNEL_READ_SUBSCRIPTIONS, # EventSub channel.subscribe (notifs sub)
|
||||
]
|
||||
|
||||
|
||||
async def _onReady(ready_event: EventData):
|
||||
logging.info('Bot Twitch prêt')
|
||||
twitchBot._loop = asyncio.get_running_loop()
|
||||
with webapp.app_context():
|
||||
await ready_event.chat.join_room(ConfigurationHelper().getValue('twitch_channel'))
|
||||
channel = ConfigurationHelper().getValue('twitch_channel')
|
||||
webapp.config["BOT_STATUS"]["twitch_connected"] = True
|
||||
webapp.config["BOT_STATUS"]["twitch_channel_name"] = channel
|
||||
await ready_event.chat.join_room(channel)
|
||||
# EventSub (follow, sub, raid) : besoin du broadcaster_id
|
||||
try:
|
||||
from twitchAPI.helper import first
|
||||
user = await first(twitchBot.twitch.get_users(logins=[channel]))
|
||||
if user:
|
||||
twitchBot._eventsub = event_notifications.create_eventsub(twitchBot.twitch, asyncio.get_event_loop())
|
||||
asyncio.create_task(event_notifications.register_eventsub_handlers(twitchBot._eventsub, user.id, ready_event.chat, channel))
|
||||
except Exception as e:
|
||||
logging.warning('EventSub non démarré: %s', e)
|
||||
asyncio.get_event_loop().create_task(twitchBot._checkOnlineStreamers())
|
||||
asyncio.get_event_loop().create_task(twitchBot._runAnnouncements())
|
||||
asyncio.get_event_loop().create_task(twitchBot._checkClips())
|
||||
|
||||
|
||||
async def _onMessage(msg: ChatMessage):
|
||||
logging.info(f'Dans {msg.room.name}, {msg.user.name} a dit : {msg.text}')
|
||||
incrementMessageCount()
|
||||
|
||||
# Stocker le message dans BOT_STATUS pour l'affichage web
|
||||
with webapp.app_context():
|
||||
from datetime import datetime
|
||||
now_ts = time.time()
|
||||
msg_timestamps = webapp.config["BOT_STATUS"].setdefault("twitch_msg_timestamps", [])
|
||||
msg_timestamps.append(now_ts)
|
||||
cutoff = now_ts - 60
|
||||
webapp.config["BOT_STATUS"]["twitch_msg_timestamps"] = [ts for ts in msg_timestamps if ts >= cutoff]
|
||||
webapp.config["BOT_STATUS"]["twitch_msg_per_minute"] = len(webapp.config["BOT_STATUS"]["twitch_msg_timestamps"])
|
||||
|
||||
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
|
||||
response = _replace_command_variables(commande.response, msg)
|
||||
await msg.reply(response)
|
||||
|
||||
|
||||
def _replace_command_variables(text: str, msg: ChatMessage) -> str:
|
||||
"""Remplace les variables de template dans la réponse d'une commande."""
|
||||
from datetime import datetime
|
||||
|
||||
result = text
|
||||
result = result.replace('{user}', msg.user.name)
|
||||
result = result.replace('{username}', msg.user.name)
|
||||
result = result.replace('{channel}', msg.room.name)
|
||||
|
||||
bot_status = webapp.config.get("BOT_STATUS", {})
|
||||
result = result.replace('{title}', bot_status.get("twitch_stream_title", ""))
|
||||
result = result.replace('{game}', bot_status.get("twitch_game_name", ""))
|
||||
result = result.replace('{viewers}', str(bot_status.get("twitch_viewer_count", 0)))
|
||||
|
||||
uptime_str = "hors ligne"
|
||||
started_at_str = bot_status.get("twitch_started_at")
|
||||
if started_at_str and bot_status.get("twitch_is_live", False):
|
||||
try:
|
||||
started_at = datetime.fromisoformat(started_at_str)
|
||||
delta = datetime.now(started_at.tzinfo) - started_at
|
||||
total_seconds = int(delta.total_seconds())
|
||||
hours, remainder = divmod(max(0, total_seconds), 3600)
|
||||
minutes, _ = divmod(remainder, 60)
|
||||
if hours > 0:
|
||||
uptime_str = f"{hours}h {minutes:02d}min"
|
||||
else:
|
||||
uptime_str = f"{minutes}min"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
result = result.replace('{uptime}', uptime_str)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# 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
|
||||
_loop = 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)
|
||||
# Laisser des tentatives de reconnexion internes plus longues avant reboot complet du client
|
||||
self.chat.reconnect_delay_steps = [0, 1, 2, 4, 8, 16, 32, 64, 128, 128, 128]
|
||||
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()
|
||||
disconnected_since = None
|
||||
while True:
|
||||
connected = self.chat.is_connected()
|
||||
if connected:
|
||||
disconnected_since = None
|
||||
else:
|
||||
if disconnected_since is None:
|
||||
disconnected_since = time.time()
|
||||
# Si la lib n'arrive pas à se reconnecter en interne pendant un moment, on relance la session complète.
|
||||
elif time.time() - disconnected_since >= 90:
|
||||
logging.warning("Chat Twitch déconnecté depuis plus de 90s, redémarrage de la session")
|
||||
break
|
||||
await asyncio.sleep(2)
|
||||
except Exception as e:
|
||||
logging.error(f'Échec de l\'authentification Twitch. Vérifiez vos identifiants et redémarrez après correction : {e}')
|
||||
logging.error(f'Échec de l\'authentification Twitch : {e}')
|
||||
finally:
|
||||
webapp.config["BOT_STATUS"]["twitch_connected"] = False
|
||||
self._loop = None
|
||||
try:
|
||||
if hasattr(self, 'chat') and self.chat:
|
||||
self.chat.stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if hasattr(self, 'twitch') and self.twitch:
|
||||
await self.twitch.close()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
logging.info("Twitch n'est pas configuré")
|
||||
|
||||
def _register_moderation_commands(self):
|
||||
# Créer des wrappers async pour chaque commande
|
||||
async def cmd_timeout(msg): await moderation.timeout_command(msg, self.twitch)
|
||||
async def cmd_ban(msg): await moderation.ban_command(msg, self.twitch)
|
||||
async def cmd_unban(msg): await moderation.unban_command(msg, self.twitch)
|
||||
async def cmd_clean(msg): await moderation.clean_command(msg, self.twitch)
|
||||
async def cmd_shieldmode(msg): await moderation.shieldmode_command(msg, self.twitch)
|
||||
async def cmd_settitle(msg): await moderation.settitle_command(msg, self.twitch)
|
||||
async def cmd_setgame(msg): await moderation.setgame_command(msg, self.twitch)
|
||||
async def cmd_subon(msg): await moderation.subon_command(msg, self.twitch)
|
||||
async def cmd_suboff(msg): await moderation.suboff_command(msg, self.twitch)
|
||||
async def cmd_follon(msg): await moderation.follon_command(msg, self.twitch)
|
||||
async def cmd_folloff(msg): await moderation.folloff_command(msg, self.twitch)
|
||||
async def cmd_emoteon(msg): await moderation.emoteon_command(msg, self.twitch)
|
||||
async def cmd_emoteoff(msg): await moderation.emoteoff_command(msg, self.twitch)
|
||||
async def cmd_ann(msg): await moderation.ann_command(msg, self.twitch)
|
||||
async def cmd_no_game(msg): await moderation.no_game_command(msg, self.twitch)
|
||||
async def cmd_multitwitch(msg): await moderation.multitwitch_command(msg, self.twitch)
|
||||
async def cmd_permit(msg): await link_filter.permit_command(msg, self.twitch)
|
||||
|
||||
from twitchbot import protondb as protondb_twitch
|
||||
async def cmd_pdb(msg): await protondb_twitch.protondb_command(msg)
|
||||
|
||||
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)
|
||||
self.chat.register_command('pdb', cmd_pdb)
|
||||
self.chat.register_command('protondb', cmd_pdb)
|
||||
|
||||
async def _checkOnlineStreamers(self):
|
||||
# pas bon faudrait faire un truc mieux
|
||||
while True :
|
||||
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)
|
||||
|
||||
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):
|
||||
retry_delay = 15
|
||||
while True:
|
||||
try:
|
||||
if not _isConfigured():
|
||||
logging.info("Twitch non configuré, nouvelle tentative dans 60s")
|
||||
with webapp.app_context():
|
||||
webapp.config["BOT_STATUS"]["twitch_connected"] = False
|
||||
time.sleep(60)
|
||||
continue
|
||||
asyncio.run(self._connect())
|
||||
logging.warning("Session Twitch perdue, reconnexion complète dans %ss", retry_delay)
|
||||
except Exception as e:
|
||||
logging.error("Déconnexion/erreur Twitch: %s", e)
|
||||
with webapp.app_context():
|
||||
webapp.config["BOT_STATUS"]["twitch_connected"] = False
|
||||
time.sleep(retry_delay)
|
||||
|
||||
# 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_message(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}")
|
||||
+212
-13
@@ -1,4 +1,7 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import discord
|
||||
|
||||
from twitchAPI.twitch import Twitch
|
||||
from twitchAPI.object.api import Stream
|
||||
@@ -11,39 +14,235 @@ 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()
|
||||
bot_status = webapp.config["BOT_STATUS"]
|
||||
was_live = bot_status.get("twitch_is_live", False)
|
||||
|
||||
try:
|
||||
streams = await _retreiveStreams(twitch, alerts)
|
||||
except Exception as e:
|
||||
logger.error(f'Erreur lors de la récupération des streams, on conserve l\'état actuel : {e}')
|
||||
return
|
||||
|
||||
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:
|
||||
bot_status["twitch_is_live"] = True
|
||||
bot_status["twitch_viewer_count"] = getattr(main_stream, 'viewer_count', 0)
|
||||
bot_status["twitch_stream_title"] = getattr(main_stream, 'title', '') or ''
|
||||
bot_status["twitch_game_name"] = getattr(main_stream, 'game_name', '') or ''
|
||||
bot_status["twitch_started_at"] = main_stream.started_at.isoformat() if getattr(main_stream, 'started_at', None) else None
|
||||
bot_status["twitch_ended_at"] = None
|
||||
bot_status["twitch_chat_clear_notice_sent"] = False
|
||||
else:
|
||||
bot_status["twitch_is_live"] = False
|
||||
bot_status["twitch_viewer_count"] = 0
|
||||
bot_status["twitch_stream_title"] = ""
|
||||
bot_status["twitch_game_name"] = ""
|
||||
bot_status["twitch_started_at"] = None
|
||||
if was_live and not bot_status.get("twitch_ended_at"):
|
||||
bot_status["twitch_ended_at"] = datetime.now().isoformat()
|
||||
if was_live and not bot_status.get("twitch_chat_clear_notice_sent"):
|
||||
messages = bot_status.setdefault("twitch_chat_messages", [])
|
||||
now_iso = datetime.now().isoformat()
|
||||
messages.append({
|
||||
'username': 'System',
|
||||
'text': 'Live terminé, ce chat sera vidé dans 1h.',
|
||||
'timestamp': now_iso,
|
||||
'is_mod': False,
|
||||
'is_subscriber': False,
|
||||
'is_vip': False,
|
||||
'color': '#22c55e',
|
||||
'panel_only': True,
|
||||
})
|
||||
if len(messages) > 100:
|
||||
messages.pop(0)
|
||||
bot_status["twitch_chat_clear_notice_sent"] = True
|
||||
|
||||
# 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.lower() == (alert.login or '').lower()), 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)
|
||||
stream = next((s for s in streams if s.user_login.lower() == (alert.login or '').lower()), None)
|
||||
if stream :
|
||||
logger.info(f'Streamer en ligne : {alert.login}')
|
||||
if not alert.online and alert.enable :
|
||||
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 not bot.loop or bot.loop.is_closed():
|
||||
logger.warning("Loop Discord non disponible pour mise à jour de présence")
|
||||
return
|
||||
|
||||
async def _retreiveStreams(twitch: Twitch, alerts : list[LiveAlert]) -> list[Stream] :
|
||||
streams : list[Stream] = []
|
||||
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}'
|
||||
)
|
||||
with webapp.app_context():
|
||||
webapp.config["BOT_STATUS"]["discord_streaming_activity"] = True
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
bot.change_presence(status=discord.Status.online, activity=activity),
|
||||
bot.loop
|
||||
)
|
||||
future.result(timeout=10)
|
||||
else:
|
||||
logger.info('Aucun stream à regarder, retour à l\'activité normale')
|
||||
with webapp.app_context():
|
||||
webapp.config["BOT_STATUS"]["discord_streaming_activity"] = False
|
||||
# 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}')
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
bot.change_presence(status=discord.Status.online, activity=discord.CustomActivity(humeur.text)),
|
||||
bot.loop
|
||||
)
|
||||
future.result(timeout=10)
|
||||
else:
|
||||
# Si pas de humeur, remettre un statut par défaut
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
bot.change_presence(status=discord.Status.online, activity=None),
|
||||
bot.loop
|
||||
)
|
||||
future.result(timeout=10)
|
||||
|
||||
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.ban_user(broadcaster_id, moderator_id, user_id, reason="Purge messages", duration=1)
|
||||
_log_action("clean", msg.user.name, viewer)
|
||||
logger.info(f'Messages de {viewer} supprimés par {msg.user.name}')
|
||||
else:
|
||||
await twitch.delete_chat_message(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_message(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
|
||||
@@ -0,0 +1,104 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from twitchAPI.chat import ChatMessage
|
||||
|
||||
from database.helpers import ConfigurationHelper
|
||||
from protondb import searhProtonDb
|
||||
from twitchbot import _user_has_twitch_permission
|
||||
from webapp import webapp
|
||||
|
||||
_last_used: float = 0.0
|
||||
|
||||
TIER_ICONS = {
|
||||
'platinum': '✅ Platinum',
|
||||
'gold': '🥇 Gold',
|
||||
'silver': '🥈 Silver',
|
||||
'bronze': '🥉 Bronze',
|
||||
'borked': '❌ Borked',
|
||||
'native': '🐧 Native',
|
||||
}
|
||||
|
||||
AC_ICONS = {
|
||||
'supported': '✅',
|
||||
'running': '⚠️',
|
||||
'broken': '❌',
|
||||
'denied': '🚫',
|
||||
'planned': '📅',
|
||||
}
|
||||
|
||||
|
||||
def _format_game_response(game: dict) -> str:
|
||||
name = game.get('name', '?')
|
||||
tier = (game.get('tier') or '').lower()
|
||||
tier_label = TIER_ICONS.get(tier, tier.capitalize() if tier else '?')
|
||||
g_id = game.get('id', '')
|
||||
|
||||
parts = [f"[{name}] {tier_label}"]
|
||||
|
||||
ac_status = (game.get('anticheat_status') or '').lower()
|
||||
if ac_status:
|
||||
ac_icon = AC_ICONS.get(ac_status, '❔')
|
||||
acs = game.get('anticheats') or []
|
||||
ac_list = ', '.join(str(ac) for ac in acs if ac)
|
||||
ac_part = f"Anti-cheat: {ac_icon} {ac_status.capitalize()}"
|
||||
if ac_list:
|
||||
ac_part += f" ({ac_list})"
|
||||
parts.append(ac_part)
|
||||
|
||||
parts.append(f"protondb.com/app/{g_id}")
|
||||
return ' | '.join(parts)
|
||||
|
||||
|
||||
async def protondb_command(msg: ChatMessage):
|
||||
global _last_used
|
||||
with webapp.app_context():
|
||||
if not ConfigurationHelper().getValue('proton_db_twitch_enable'):
|
||||
return
|
||||
permission = ConfigurationHelper().getValue('proton_db_twitch_permission') or 'viewer'
|
||||
cooldown = int(ConfigurationHelper().getValue('proton_db_twitch_cooldown') or 0)
|
||||
|
||||
if not _user_has_twitch_permission(msg, permission):
|
||||
return
|
||||
|
||||
if cooldown > 0:
|
||||
elapsed = time.time() - _last_used
|
||||
if elapsed < cooldown:
|
||||
remaining = int(cooldown - elapsed)
|
||||
await msg.reply(f"@{msg.user.name} La commande !pdb est en cooldown, réessaie dans {remaining}s.")
|
||||
return
|
||||
_last_used = time.time()
|
||||
|
||||
text = msg.text
|
||||
for prefix in ('!protondb', '!pdb'):
|
||||
if text.lower().startswith(prefix):
|
||||
text = text[len(prefix):]
|
||||
break
|
||||
name = text.strip()
|
||||
|
||||
if not name:
|
||||
await msg.reply(f"@{msg.user.name} Utilisation : !pdb <nom du jeu> Exemple : !pdb Elden Ring")
|
||||
return
|
||||
|
||||
def _search():
|
||||
with webapp.app_context():
|
||||
return searhProtonDb(name)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
games = await loop.run_in_executor(None, _search)
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur ProtonDB Twitch pour "{name}": {e}')
|
||||
await msg.reply(f"@{msg.user.name} Erreur lors de la recherche ProtonDB.")
|
||||
return
|
||||
|
||||
if not games:
|
||||
await msg.reply(f"@{msg.user.name} Aucun jeu trouvé pour \"{name}\" sur Steam.")
|
||||
return
|
||||
|
||||
for game in games[:3]:
|
||||
response = _format_game_response(game)
|
||||
if len(response) > 500:
|
||||
response = response[:497] + '...'
|
||||
await msg.reply(response)
|
||||
+62
-1
@@ -1,5 +1,66 @@
|
||||
import os
|
||||
from flask import Flask
|
||||
from flask_login import LoginManager
|
||||
|
||||
webapp = Flask(__name__)
|
||||
|
||||
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation
|
||||
# Secret key pour les sessions (Flask-Login)
|
||||
webapp.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-production")
|
||||
|
||||
# État des bots (mis à jour par les bots, lu par le panneau)
|
||||
webapp.config["BOT_STATUS"] = {
|
||||
"discord_connected": False,
|
||||
"discord_guild_count": 0,
|
||||
"discord_streaming_activity": False,
|
||||
"twitch_connected": False,
|
||||
"twitch_channel_name": None,
|
||||
"twitch_is_live": False,
|
||||
"twitch_viewer_count": 0,
|
||||
"twitch_stream_title": "",
|
||||
"twitch_game_name": "",
|
||||
"twitch_started_at": None,
|
||||
"twitch_ended_at": None,
|
||||
"twitch_chat_clear_notice_sent": False,
|
||||
"twitch_msg_per_minute": 0,
|
||||
"twitch_msg_timestamps": [], # Unix timestamps des 60 dernières secondes
|
||||
"twitch_chat_messages": [], # Derniers messages du chat (max 100)
|
||||
"shoutbox_heartbeats": {}, # {"username": datetime} — présence des modos
|
||||
}
|
||||
|
||||
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, patreon
|
||||
|
||||
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,33 +1,80 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
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.helpers import ConfigurationHelper
|
||||
from discordbot import bot
|
||||
|
||||
RULES_FORM_KEYS = frozenset({
|
||||
'rules_channel_id',
|
||||
'rules_arrival_role_id',
|
||||
'rules_validated_role_id',
|
||||
'rules_presentation_channel_id',
|
||||
'rules_embed_title',
|
||||
'rules_embed_body',
|
||||
'rules_button_label',
|
||||
})
|
||||
|
||||
SKIP_FORM_KEYS = frozenset({
|
||||
'moderation_staff_role_ids',
|
||||
'rules_ack_section_in_form',
|
||||
'moderation_roles_in_form',
|
||||
})
|
||||
|
||||
|
||||
def _form_int_str(raw: str | None) -> str:
|
||||
s = (raw or '').strip()
|
||||
return s if s.isdigit() else '0'
|
||||
|
||||
|
||||
@webapp.route("/configurations")
|
||||
@require_page("configurations")
|
||||
def openConfigurations():
|
||||
return render_template("configurations.html", configuration = ConfigurationHelper(), channels = bot.getAllTextChannel(), roles = bot.getAllRoles())
|
||||
return render_template("configurations.html", configuration=ConfigurationHelper(), channels=bot.getAllTextChannel(), voice_channels=bot.getAllVoiceChannels(), roles=bot.getAllRoles())
|
||||
|
||||
@webapp.route("/configurations/update", methods=['POST'])
|
||||
@require_page("configurations")
|
||||
def updateConfiguration():
|
||||
checkboxes = {
|
||||
'humble_bundle_enable': 'humble_bundle_channel',
|
||||
'proton_db_enable_enable': 'proton_db_api_id',
|
||||
'proton_db_twitch_enable': 'proton_db_api_id',
|
||||
'moderation_enable': 'moderation_staff_role_ids',
|
||||
'moderation_ban_enable': 'moderation_staff_role_ids',
|
||||
'moderation_kick_enable': 'moderation_staff_role_ids',
|
||||
'welcome_enable': 'welcome_channel_id',
|
||||
'leave_enable': 'leave_channel_id'
|
||||
'leave_enable': 'leave_channel_id',
|
||||
'auto_rooms_enable': 'auto_rooms_channel_id',
|
||||
'twitch_commands_enable': 'twitch_channel',
|
||||
'rules_ack_enable': 'rules_channel_id',
|
||||
}
|
||||
|
||||
# Ne mettre à jour les rôles staff que si la liste a été rendue dans le formulaire.
|
||||
# Sinon (bot pas encore prêt, guilds vides), getlist est vide et on écrasait la config en base.
|
||||
staff_roles = request.form.getlist('moderation_staff_role_ids')
|
||||
if request.form.get('moderation_roles_in_form'):
|
||||
if staff_roles:
|
||||
ConfigurationHelper().createOrUpdate('moderation_staff_role_ids', ','.join(staff_roles))
|
||||
else:
|
||||
ConfigurationHelper().createOrUpdate('moderation_staff_role_ids', '')
|
||||
|
||||
if request.form.get('rules_ack_section_in_form'):
|
||||
ch = ConfigurationHelper()
|
||||
ch.createOrUpdate('rules_channel_id', _form_int_str(request.form.get('rules_channel_id')))
|
||||
ch.createOrUpdate('rules_arrival_role_id', _form_int_str(request.form.get('rules_arrival_role_id')))
|
||||
ch.createOrUpdate('rules_validated_role_id', _form_int_str(request.form.get('rules_validated_role_id')))
|
||||
ch.createOrUpdate('rules_presentation_channel_id', _form_int_str(request.form.get('rules_presentation_channel_id')))
|
||||
ch.createOrUpdate('rules_embed_title', (request.form.get('rules_embed_title') or '').strip())
|
||||
ch.createOrUpdate('rules_embed_body', request.form.get('rules_embed_body') or '')
|
||||
ch.createOrUpdate('rules_button_label', (request.form.get('rules_button_label') or '').strip())
|
||||
|
||||
for key in request.form:
|
||||
if key == 'moderation_staff_role_ids':
|
||||
if key in SKIP_FORM_KEYS:
|
||||
continue
|
||||
if request.form.get('rules_ack_section_in_form') and key in RULES_FORM_KEYS:
|
||||
continue
|
||||
value = request.form.get(key)
|
||||
if value and value.strip():
|
||||
@@ -40,3 +87,18 @@ def updateConfiguration():
|
||||
db.session.commit()
|
||||
return redirect(request.referrer)
|
||||
|
||||
|
||||
@webapp.route("/configurations/publish-rules", methods=['POST'])
|
||||
@require_page("configurations")
|
||||
def publishRulesMessage():
|
||||
from discordbot.rules_ack import publish_rules_embed_sync
|
||||
|
||||
if not bot.loop or bot.loop.is_closed():
|
||||
flash("Le bot Discord n'est pas connecté.", "error")
|
||||
return redirect(url_for("openConfigurations"))
|
||||
|
||||
ok, msg = publish_rules_embed_sync(bot)
|
||||
flash(msg, "success" if ok else "error")
|
||||
if not ok:
|
||||
logging.warning("publishRulesMessage: %s", msg)
|
||||
return redirect(url_for("openConfigurations"))
|
||||
|
||||
@@ -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'))
|
||||
|
||||
+17
-1
@@ -1,6 +1,22 @@
|
||||
from flask import render_template
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page
|
||||
from database.models import ModerationEvent, TwitchAnnouncement, TwitchModerationLog
|
||||
|
||||
@webapp.route("/")
|
||||
@require_page("index")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
status = webapp.config["BOT_STATUS"]
|
||||
sanctions_count = ModerationEvent.query.count()
|
||||
twitch_announcements_count = TwitchAnnouncement.query.count()
|
||||
twitch_moderation_count = TwitchModerationLog.query.count()
|
||||
return render_template(
|
||||
"index.html",
|
||||
discord_connected=status["discord_connected"],
|
||||
discord_guild_count=status["discord_guild_count"],
|
||||
sanctions_count=sanctions_count,
|
||||
twitch_connected=status["twitch_connected"],
|
||||
twitch_channel_name=status["twitch_channel_name"],
|
||||
twitch_announcements_count=twitch_announcements_count,
|
||||
twitch_moderation_count=twitch_moderation_count,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
+53
-2
@@ -1,28 +1,79 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import ModerationEvent
|
||||
|
||||
def _top_sanctioned():
|
||||
return (
|
||||
db.session.query(
|
||||
ModerationEvent.discord_id,
|
||||
db.func.max(ModerationEvent.username).label("username"),
|
||||
db.func.count(ModerationEvent.id).label("count"),
|
||||
)
|
||||
.group_by(ModerationEvent.discord_id)
|
||||
.order_by(db.func.count(ModerationEvent.id).desc())
|
||||
.limit(3)
|
||||
.all()
|
||||
)
|
||||
|
||||
def _top_moderators():
|
||||
return (
|
||||
db.session.query(
|
||||
ModerationEvent.staff_id,
|
||||
db.func.max(ModerationEvent.staff_name).label("staff_name"),
|
||||
db.func.count(ModerationEvent.id).label("count"),
|
||||
)
|
||||
.group_by(ModerationEvent.staff_id)
|
||||
.order_by(db.func.count(ModerationEvent.id).desc())
|
||||
.limit(3)
|
||||
.all()
|
||||
)
|
||||
|
||||
@webapp.route("/moderation")
|
||||
@require_page("moderation")
|
||||
def moderation():
|
||||
events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
|
||||
return render_template("moderation.html", events=events, event=None)
|
||||
top_sanctioned = _top_sanctioned()
|
||||
top_moderators = _top_moderators()
|
||||
return render_template(
|
||||
"moderation.html",
|
||||
events=events,
|
||||
event=None,
|
||||
top_sanctioned=top_sanctioned,
|
||||
top_moderators=top_moderators,
|
||||
)
|
||||
|
||||
@webapp.route("/moderation/edit/<int:event_id>")
|
||||
@require_page("moderation")
|
||||
def open_edit_moderation_event(event_id):
|
||||
event = ModerationEvent.query.get_or_404(event_id)
|
||||
events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
|
||||
return render_template("moderation.html", events=events, event=event)
|
||||
top_sanctioned = _top_sanctioned()
|
||||
top_moderators = _top_moderators()
|
||||
return render_template(
|
||||
"moderation.html",
|
||||
events=events,
|
||||
event=event,
|
||||
top_sanctioned=top_sanctioned,
|
||||
top_moderators=top_moderators,
|
||||
)
|
||||
|
||||
@webapp.route("/moderation/update/<int:event_id>", methods=['POST'])
|
||||
@require_page("moderation")
|
||||
def update_moderation_event(event_id):
|
||||
if not can_write_page("moderation"):
|
||||
return render_template("403.html"), 403
|
||||
event = ModerationEvent.query.get_or_404(event_id)
|
||||
event.reason = request.form.get('reason')
|
||||
db.session.commit()
|
||||
return redirect(url_for('moderation'))
|
||||
|
||||
@webapp.route("/moderation/delete/<int:event_id>")
|
||||
@require_page("moderation")
|
||||
def delete_moderation_event(event_id):
|
||||
if not can_write_page("moderation"):
|
||||
return render_template("403.html"), 403
|
||||
event = ModerationEvent.query.get_or_404(event_id)
|
||||
db.session.delete(event)
|
||||
db.session.commit()
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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 database.models import PatreonPost
|
||||
from discordbot import bot
|
||||
from discordbot.patreon import send_post_to_discord_sync
|
||||
|
||||
|
||||
def _parse_mention_config(raw: str | None) -> tuple[bool, bool, list[str]]:
|
||||
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)
|
||||
|
||||
|
||||
def _format_pub_date(raw: str | None) -> str:
|
||||
if not raw or not str(raw).strip():
|
||||
return ""
|
||||
try:
|
||||
from email.utils import parsedate_to_datetime
|
||||
dt = parsedate_to_datetime(raw)
|
||||
return dt.strftime("%d/%m/%Y %H:%M")
|
||||
except Exception:
|
||||
return raw[:16] if len(raw or "") >= 16 else (raw or "")
|
||||
|
||||
|
||||
@webapp.route("/patreon")
|
||||
@require_page("patreon")
|
||||
def openPatreon():
|
||||
helper = ConfigurationHelper()
|
||||
channels = bot.getAllTextChannel()
|
||||
roles = bot.getAllRoles()
|
||||
raw_mention = helper.getValue("patreon_mention")
|
||||
mention_everyone, mention_here, mention_role_ids = _parse_mention_config(raw_mention)
|
||||
|
||||
posts = PatreonPost.query.order_by(PatreonPost.published_at.desc()).all()
|
||||
for p in posts:
|
||||
p.published_formatted = _format_pub_date(p.published_at)
|
||||
|
||||
return render_template(
|
||||
"patreon.html",
|
||||
configuration=helper,
|
||||
channels=channels,
|
||||
roles=roles,
|
||||
mention_everyone=mention_everyone,
|
||||
mention_here=mention_here,
|
||||
mention_role_ids=mention_role_ids,
|
||||
posts=posts,
|
||||
)
|
||||
|
||||
|
||||
@webapp.route("/patreon/update", methods=["POST"])
|
||||
@require_page("patreon")
|
||||
def updatePatreon():
|
||||
if not can_write_page("patreon"):
|
||||
return render_template("403.html"), 403
|
||||
helper = ConfigurationHelper()
|
||||
enable = request.form.get("patreon_enable") in ("on", "1", "true", "yes")
|
||||
creator = (request.form.get("patreon_creator") or "").strip()
|
||||
channel_id = request.form.get("patreon_channel_id")
|
||||
|
||||
mention_parts = []
|
||||
if request.form.get("patreon_mention_everyone"):
|
||||
mention_parts.append("everyone")
|
||||
if request.form.get("patreon_mention_here"):
|
||||
mention_parts.append("here")
|
||||
mention_parts.extend(request.form.getlist("patreon_mention_roles"))
|
||||
|
||||
helper.createOrUpdate("patreon_enable", "true" if enable else "false")
|
||||
helper.createOrUpdate("patreon_creator", creator)
|
||||
if channel_id:
|
||||
try:
|
||||
helper.createOrUpdate("patreon_channel_id", str(int(channel_id)))
|
||||
except ValueError:
|
||||
pass
|
||||
helper.createOrUpdate("patreon_mention", ",".join(mention_parts))
|
||||
db.session.commit()
|
||||
return redirect(url_for("openPatreon") + "?msg=Configuration enregistrée.&type=success")
|
||||
|
||||
|
||||
@webapp.route("/patreon/send", methods=["POST"])
|
||||
@require_page("patreon")
|
||||
def sendPatreonToDiscord():
|
||||
if not can_write_page("patreon"):
|
||||
return render_template("403.html"), 403
|
||||
guid = (request.form.get("guid") or "").strip()
|
||||
if not guid:
|
||||
return redirect(url_for("openPatreon") + "?" + urlencode({"msg": "Post manquant.", "type": "error"}))
|
||||
ok, message = send_post_to_discord_sync(bot, guid)
|
||||
msg_type = "success" if ok else "error"
|
||||
return redirect(url_for("openPatreon") + "?" + urlencode({"msg": message, "type": msg_type}))
|
||||
+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"))
|
||||
@@ -1,603 +0,0 @@
|
||||
/* MVP.css v1.17.2 - https://github.com/andybrewer/mvp */
|
||||
|
||||
:root {
|
||||
--active-brightness: 0.85;
|
||||
--border-radius: 5px;
|
||||
--box-shadow: 2px 2px 10px;
|
||||
--color-accent: #118bee15;
|
||||
--color-bg: #fff;
|
||||
--color-bg-secondary: #e9e9e9;
|
||||
--color-link: #118bee;
|
||||
--color-secondary: #920de9;
|
||||
--color-secondary-accent: #920de90b;
|
||||
--color-shadow: #f4f4f4;
|
||||
--color-table: #118bee;
|
||||
--color-text: #000;
|
||||
--color-text-secondary: #999;
|
||||
--color-scrollbar: #cacae8;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
--hover-brightness: 1.2;
|
||||
--justify-important: center;
|
||||
--justify-normal: left;
|
||||
--line-height: 1.5;
|
||||
--width-card: 285px;
|
||||
--width-card-medium: 460px;
|
||||
--width-card-wide: 800px;
|
||||
--width-content: 1080px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[color-mode="user"] {
|
||||
--color-accent: #0097fc4f;
|
||||
--color-bg: #333;
|
||||
--color-bg-secondary: #555;
|
||||
--color-link: #0097fc;
|
||||
--color-secondary: #e20de9;
|
||||
--color-secondary-accent: #e20de94f;
|
||||
--color-shadow: #bbbbbb20;
|
||||
--color-table: #0097fc;
|
||||
--color-text: #f7f7f7;
|
||||
--color-text-secondary: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
article aside {
|
||||
background: var(--color-secondary-accent);
|
||||
border-left: 4px solid var(--color-secondary);
|
||||
padding: 0.01rem 0.8rem;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-family);
|
||||
line-height: var(--line-height);
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
footer,
|
||||
header,
|
||||
main {
|
||||
margin: 0 auto;
|
||||
max-width: var(--width-content);
|
||||
/* padding: 3rem 1rem; */
|
||||
padding: 1rem 1rem;
|
||||
}
|
||||
|
||||
hr {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: none;
|
||||
height: 1px;
|
||||
margin: 4rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: var(--justify-important);
|
||||
}
|
||||
|
||||
section img,
|
||||
article img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
section pre {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
section aside {
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow) var(--color-shadow);
|
||||
margin: 1rem;
|
||||
padding: 1.25rem;
|
||||
width: var(--width-card);
|
||||
}
|
||||
|
||||
section aside:hover {
|
||||
box-shadow: var(--box-shadow) var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Headers */
|
||||
article header,
|
||||
div header,
|
||||
main header {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: var(--justify-important);
|
||||
}
|
||||
|
||||
header a b,
|
||||
header a em,
|
||||
header a i,
|
||||
header a strong {
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* header nav img {
|
||||
margin: 1rem 0;
|
||||
} */
|
||||
|
||||
section header {
|
||||
padding-top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Nav */
|
||||
nav {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-weight: bold;
|
||||
justify-content: space-between;
|
||||
/* margin-bottom: 7rem; */
|
||||
}
|
||||
|
||||
nav ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
nav ul li {
|
||||
display: inline-block;
|
||||
margin: 0 0.5rem;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Nav Dropdown */
|
||||
nav ul li:hover ul {
|
||||
display: block;
|
||||
}
|
||||
|
||||
nav ul li ul {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow) var(--color-shadow);
|
||||
display: none;
|
||||
height: auto;
|
||||
left: -2px;
|
||||
padding: 0.5rem 1rem;
|
||||
position: absolute;
|
||||
top: 1.7rem;
|
||||
white-space: nowrap;
|
||||
width: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
nav ul li ul::before {
|
||||
/* fill gap above to make mousing over them easier */
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -0.5rem;
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
nav ul li ul li,
|
||||
nav ul li ul li a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Nav for Mobile */
|
||||
@media (max-width: 768px) {
|
||||
nav {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
nav ul li {
|
||||
width: calc(100% - 1em);
|
||||
}
|
||||
|
||||
nav ul li ul {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
display: block;
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
code,
|
||||
samp {
|
||||
background-color: var(--color-accent);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-text);
|
||||
display: inline-block;
|
||||
margin: 0 0.1rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
details {
|
||||
margin: 1.3rem 0;
|
||||
}
|
||||
|
||||
details summary {
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: var(--line-height);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.1rem;
|
||||
}
|
||||
|
||||
ol li,
|
||||
ul li {
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.75rem 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 1rem 0;
|
||||
max-width: var(--width-card-wide);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
pre code,
|
||||
pre samp {
|
||||
display: block;
|
||||
max-width: var(--width-card-wide);
|
||||
padding: 0.5rem 2rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
sup {
|
||||
background-color: var(--color-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-bg);
|
||||
font-size: xx-small;
|
||||
font-weight: bold;
|
||||
margin: 0.2rem;
|
||||
padding: 0.2rem 0.3rem;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: var(--color-link);
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
filter: brightness(var(--hover-brightness));
|
||||
}
|
||||
|
||||
a:active {
|
||||
filter: brightness(var(--active-brightness));
|
||||
}
|
||||
|
||||
a b,
|
||||
a em,
|
||||
a i,
|
||||
a strong,
|
||||
button,
|
||||
input[type="submit"] {
|
||||
border-radius: var(--border-radius);
|
||||
display: inline-block;
|
||||
font-size: medium;
|
||||
font-weight: bold;
|
||||
line-height: var(--line-height);
|
||||
margin: 0.5rem 0;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
button,
|
||||
input[type="submit"] {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
button:hover,
|
||||
input[type="submit"]:hover {
|
||||
cursor: pointer;
|
||||
filter: brightness(var(--hover-brightness));
|
||||
}
|
||||
|
||||
button:active,
|
||||
input[type="submit"]:active {
|
||||
filter: brightness(var(--active-brightness));
|
||||
}
|
||||
|
||||
a b,
|
||||
a strong,
|
||||
button,
|
||||
input[type="submit"] {
|
||||
background-color: var(--color-link);
|
||||
border: 2px solid var(--color-link);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
a em,
|
||||
a i {
|
||||
border: 2px solid var(--color-link);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-link);
|
||||
display: inline-block;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
article aside a {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
/* Images */
|
||||
figure {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
figure img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
figure figcaption {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
button:disabled,
|
||||
input:disabled {
|
||||
background: var(--color-bg-secondary);
|
||||
border-color: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button[disabled]:hover,
|
||||
input[type="submit"][disabled]:hover {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
form {
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow) var(--color-shadow);
|
||||
display: block;
|
||||
max-width: var(--width-card-wide);
|
||||
min-width: var(--width-card);
|
||||
padding: 1.5rem;
|
||||
text-align: var(--justify-normal);
|
||||
}
|
||||
|
||||
form header {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
input,
|
||||
label,
|
||||
select,
|
||||
textarea {
|
||||
display: block;
|
||||
font-size: inherit;
|
||||
max-width: var(--width-card-wide);
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
input[type="checkbox"]+label,
|
||||
input[type="radio"]+label {
|
||||
display: inline-block;
|
||||
font-weight: normal;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="email"],
|
||||
textarea {
|
||||
width: calc(100% - 1.6rem);
|
||||
}
|
||||
|
||||
input[readonly],
|
||||
textarea[readonly] {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
/* Popups */
|
||||
dialog {
|
||||
max-width: 90%;
|
||||
max-height: 85dvh;
|
||||
margin: auto;
|
||||
padding-block: 0;
|
||||
padding-inline: 20px;
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: 0.5rem;
|
||||
overscroll-behavior: contain;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
/* Hide scrollbar for Firefox */
|
||||
-ms-overflow-style: none;
|
||||
/* Hide scrollbar for IE and Edge */
|
||||
scrollbar-color: transparent transparent;
|
||||
animation: bottom-to-top 0.25s ease-in-out forwards;
|
||||
}
|
||||
|
||||
dialog::-webkit-scrollbar {
|
||||
width: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
dialog::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
dialog::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@media (min-width: 650px) {
|
||||
dialog {
|
||||
max-width: 39rem;
|
||||
}
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@keyframes bottom-to-top {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10%);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
dialog hr {
|
||||
margin-block: 1rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
border-spacing: 0;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
table td,
|
||||
table th,
|
||||
table tr {
|
||||
padding: 0.4rem 0.8rem;
|
||||
text-align: var(--justify-important);
|
||||
}
|
||||
|
||||
table thead {
|
||||
background-color: var(--color-table);
|
||||
border-collapse: collapse;
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-bg);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
table thead tr:first-child th:first-child {
|
||||
border-top-left-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
table thead tr:first-child th:last-child {
|
||||
border-top-right-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
table thead th:first-child,
|
||||
table tr td:first-child {
|
||||
text-align: var(--justify-normal);
|
||||
}
|
||||
|
||||
table tr:nth-child(even) {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Quotes */
|
||||
blockquote {
|
||||
display: block;
|
||||
font-size: x-large;
|
||||
line-height: var(--line-height);
|
||||
margin: 1rem auto;
|
||||
max-width: var(--width-card-medium);
|
||||
padding: 1.5rem 1rem;
|
||||
text-align: var(--justify-important);
|
||||
}
|
||||
|
||||
blockquote footer {
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
font-size: small;
|
||||
line-height: var(--line-height);
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-scrollbar) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-scrollbar);
|
||||
border-radius: 10px;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
header nav img {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
table th,
|
||||
table td {
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
table.live-alert tr td:last-child {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
a.icon {
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -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 %}
|
||||
+107
-32
@@ -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>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Commandes de Mamie</h1>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Gérez les commandes personnalisées du bot. Ces commandes peuvent être activées sur Discord et/ou Twitch selon vos besoins.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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>Commande</th>
|
||||
<th>Réponse</th>
|
||||
<th>Discord</th>
|
||||
<th>Twitch</th>
|
||||
<th>Actions</th>
|
||||
<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>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% 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">
|
||||
<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>
|
||||
<a href="{{ url_for('toggle_twitch_commande', commande_id = commande.id) }}" class="icon">
|
||||
<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>
|
||||
<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 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>
|
||||
</table>
|
||||
</div>
|
||||
</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 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="discord_enable">Discord</label>
|
||||
<input name="discord_enable" type="checkbox" checked />
|
||||
<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="twitch_enable">Twitch</label>
|
||||
<input name="twitch_enable" type="checkbox" unchecked />
|
||||
<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>
|
||||
<input type="Submit" value="Ajouter">
|
||||
</form>
|
||||
|
||||
<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,112 +1,259 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Configuration de Mamie</h1>
|
||||
<p>Configurez les tokens Discord, les notifications Humble Bundle et l'API Twitch.</p>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-4 space-y-2">
|
||||
{% for category, msg in messages %}
|
||||
<div class="p-4 rounded-lg {{ 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200' if category == 'success' else 'bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200' }}">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Configuration de Mamie</h1>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Configurez les tokens Discord, les notifications Humble Bundle et l'API Twitch.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Discord</h2>
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
<fieldset>
|
||||
<legend>API Discord</legend>
|
||||
<label for="discord_token">Token Discord (caché)</label>
|
||||
<input name="discord_token" type="password" placeholder="Votre token Discord" />
|
||||
<small>Nécessite un redémarrage après modification</small>
|
||||
</fieldset>
|
||||
<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>
|
||||
|
||||
<fieldset>
|
||||
<legend>Messages de bienvenue</legend>
|
||||
<label for="welcome_enable">
|
||||
<input type="checkbox" name="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked="checked"{% endif %}>
|
||||
Activer le message de bienvenue pour les nouveaux membres
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-6">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">API Discord</h3>
|
||||
<div>
|
||||
<label for="discord_token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Token Discord (caché)</label>
|
||||
<input name="discord_token" id="discord_token" type="password"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
||||
placeholder="Votre token Discord"/>
|
||||
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">Nécessite un redémarrage après modification</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Messages de bienvenue</h3>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer le message de bienvenue pour les nouveaux membres</span>
|
||||
</label>
|
||||
|
||||
<label for="welcome_channel_id">Canal de bienvenue</label>
|
||||
<select name="welcome_channel_id">
|
||||
<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="selected"{% endif %}>
|
||||
{{channel.name}}
|
||||
</option>
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('welcome_channel_id')==channel.id %}selected{% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label for="welcome_message">Message personnalisé de bienvenue</label>
|
||||
<textarea name="welcome_message" rows="3" placeholder="Bienvenue {member.mention} sur le serveur !">{{ configuration.getValue('welcome_message') }}</textarea>
|
||||
<small>
|
||||
<strong>Syntaxes disponibles :</strong><br>
|
||||
• <code>{member.mention}</code> - Mentionne l'utilisateur (@NomUtilisateur)<br>
|
||||
• <code>{member.name}</code> - Nom d'utilisateur (sans mention)<br>
|
||||
• <code>{member.display_name}</code> - Surnom sur le serveur<br>
|
||||
• <code>{member.id}</code> - ID de l'utilisateur<br>
|
||||
• <code>{server.name}</code> - Nom du serveur<br>
|
||||
• <code>{server.member_count}</code> - Nombre total de membres<br>
|
||||
• <code><#ID_DU_CHANNEL></code> - Mentionne un salon (ex: <#123456789012345678>)
|
||||
</small>
|
||||
</fieldset>
|
||||
<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>
|
||||
|
||||
<fieldset>
|
||||
<legend>Messages de départ</legend>
|
||||
<label for="leave_enable">
|
||||
<input type="checkbox" name="leave_enable" {% if configuration.getValue('leave_enable') %}checked="checked"{% endif %}>
|
||||
Activer le message de départ quand un membre quitte le serveur
|
||||
<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">Règlement (embed + bouton)</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Le <strong>rôle d'arrivée</strong> est attribué <strong>dès qu'un membre rejoint le serveur</strong>. Le <strong>rôle membre validé</strong> est attribué <strong>uniquement</strong> quand il clique sur le bouton « J'ai lu le règlement » (le rôle d'arrivée est alors retiré s'il est encore présent). Le canal présentation sert uniquement d'indication dans le message de confirmation après le clic.
|
||||
</p>
|
||||
<input type="hidden" name="rules_ack_section_in_form" value="1">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="rules_ack_enable" {% if configuration.getValue('rules_ack_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 règlement avec bouton</span>
|
||||
</label>
|
||||
|
||||
<label for="leave_channel_id">Canal de départ</label>
|
||||
<select name="leave_channel_id">
|
||||
<div>
|
||||
<label for="rules_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal du règlement (message + bouton)</label>
|
||||
<select name="rules_channel_id" id="rules_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 —</option>
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('leave_channel_id')==channel.id %}selected="selected"{% endif %}>
|
||||
{{channel.name}}
|
||||
</option>
|
||||
<option value="{{ channel.id }}" {% if configuration.getIntValue('rules_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="leave_message">Message personnalisé de départ</label>
|
||||
<textarea name="leave_message" rows="3" placeholder="{member.mention} a quitté le serveur.">{{ configuration.getValue('leave_message') }}</textarea>
|
||||
<small>
|
||||
<strong>Syntaxes disponibles :</strong><br>
|
||||
• <code>{member.mention}</code> - Mentionne l'utilisateur (@NomUtilisateur)<br>
|
||||
• <code>{member.name}</code> - Nom d'utilisateur (sans mention)<br>
|
||||
• <code>{member.display_name}</code> - Surnom sur le serveur<br>
|
||||
• <code>{member.id}</code> - ID de l'utilisateur<br>
|
||||
• <code>{server.name}</code> - Nom du serveur<br>
|
||||
• <code>{server.member_count}</code> - Nombre total de membres<br>
|
||||
• <code><#ID_DU_CHANNEL></code> - Mentionne un salon (ex: <#123456789012345678>)
|
||||
</small>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Modération</legend>
|
||||
<label for="moderation_enable">
|
||||
<input type="checkbox" name="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked="checked"{% endif %}>
|
||||
Activer les commandes d'avertissement (!warn, !unwarn, !inspect)
|
||||
</label>
|
||||
|
||||
<label for="moderation_ban_enable">
|
||||
<input type="checkbox" name="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked="checked"{% endif %}>
|
||||
Activer les commandes de bannissement (!ban, !unban)
|
||||
</label>
|
||||
|
||||
<label for="moderation_kick_enable">
|
||||
<input type="checkbox" name="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked="checked"{% endif %}>
|
||||
Activer la commande d'expulsion (!kick)
|
||||
</label>
|
||||
|
||||
<label for="moderation_log_channel_id">Canal de logs de modération</label>
|
||||
<select name="moderation_log_channel_id">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('moderation_log_channel_id')==channel.id %}selected="selected"{% endif %}>
|
||||
{{channel.name}}
|
||||
</option>
|
||||
</div>
|
||||
<div>
|
||||
<label for="rules_embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre de l'embed</label>
|
||||
<input name="rules_embed_title" id="rules_embed_title" 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-indigo-500 focus:border-transparent transition-all"
|
||||
value="{{ configuration.getValue('rules_embed_title') or '' }}"
|
||||
placeholder="Bienvenue"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="rules_embed_body" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Texte du règlement (description de l'embed, markdown Discord)</label>
|
||||
<textarea name="rules_embed_body" id="rules_embed_body" rows="8"
|
||||
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="Lis le règlement puis clique sur le bouton ci-dessous…">{{ configuration.getValue('rules_embed_body') or '' }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="rules_button_label" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Libellé du bouton</label>
|
||||
<input name="rules_button_label" id="rules_button_label" 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-indigo-500 focus:border-transparent transition-all"
|
||||
value="{{ configuration.getValue('rules_button_label') or '' }}"
|
||||
placeholder="J'ai lu le règlement"/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="rules_arrival_role_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôle d'arrivée (à la connexion uniquement)</label>
|
||||
<select name="rules_arrival_role_id" id="rules_arrival_role_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="">— Aucun —</option>
|
||||
{% for guild_data in roles %}
|
||||
<optgroup label="{{ guild_data.guild_name }}">
|
||||
{% for role in guild_data.roles %}
|
||||
<option value="{{ role.id }}" {% if configuration.getIntValue('rules_arrival_role_id') == role.id %}selected{% endif %}>{{ role.name }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>Toutes les actions de modération seront notifiées dans ce canal</small>
|
||||
</div>
|
||||
<div>
|
||||
<label for="rules_validated_role_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôle membre validé (au clic sur le bouton uniquement)</label>
|
||||
<select name="rules_validated_role_id" id="rules_validated_role_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="">— Aucun —</option>
|
||||
{% for guild_data in roles %}
|
||||
<optgroup label="{{ guild_data.guild_name }}">
|
||||
{% for role in guild_data.roles %}
|
||||
<option value="{{ role.id }}" {% if configuration.getIntValue('rules_validated_role_id') == role.id %}selected{% endif %}>{{ role.name }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="rules_presentation_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal présentation (optionnel, texte d'aide après le bouton)</label>
|
||||
<select name="rules_presentation_channel_id" id="rules_presentation_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="">— Désactivé —</option>
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.id }}" {% if configuration.getIntValue('rules_presentation_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Mentionné dans le message éphémère après le clic (« Tu peux aller te présenter dans … »). Aucun rôle n'est attribué automatiquement sur ce canal.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>Rôles Staff autorisés</label>
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Messages de départ</h3>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="leave_enable" {% if configuration.getValue('leave_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer le message quand un membre quitte le serveur</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label for="leave_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de départ</label>
|
||||
<select name="leave_channel_id" id="leave_channel_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('leave_channel_id')==channel.id %}selected{% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="leave_message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message personnalisé</label>
|
||||
<textarea name="leave_message" id="leave_message" rows="3"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all resize-y"
|
||||
placeholder="{member.name} a quitté le serveur.">{{ configuration.getValue('leave_message') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="auto-rooms" class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Auto Rooms (salons vocaux temporaires)</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Quand un membre rejoint le canal vocal configuré ci-dessous, un salon vocal temporaire est créé. Le message de configuration avec les réactions apparaît dans la <strong>partie texte du vocal</strong> (onglet Discussion à droite quand on ouvre le salon). Seul le propriétaire peut réagir.
|
||||
</p>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="auto_rooms_enable" {% if configuration.getValue('auto_rooms_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les Auto Rooms</span>
|
||||
</label>
|
||||
<div>
|
||||
<label for="auto_rooms_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal vocal à rejoindre pour créer un salon</label>
|
||||
<select name="auto_rooms_channel_id" id="auto_rooms_channel_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
|
||||
<option value="">— Choisir un canal vocal —</option>
|
||||
{% for channel in voice_channels %}
|
||||
<option value="{{ channel.id }}" {% if configuration.getIntValue('auto_rooms_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }} (vocal)</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Ex. « + Créer votre salon » — les membres qui rejoignent ce canal obtiennent un salon vocal dédié.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Modération</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les commandes d'avertissement (!warn, !unwarn, !inspect)</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les commandes de bannissement (!ban, !unban)</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Activer la commande d'expulsion (!kick)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="moderation_log_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de logs de modération</label>
|
||||
<select name="moderation_log_channel_id" id="moderation_log_channel_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('moderation_log_channel_id')==channel.id %}selected{% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Toutes les actions de modération seront notifiées dans ce canal</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôles Staff autorisés</label>
|
||||
{% if roles %}
|
||||
<input type="hidden" name="moderation_roles_in_form" value="1">
|
||||
{% endif %}
|
||||
{% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %}
|
||||
|
||||
{% if roles|length > 1 %}
|
||||
<div class="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="tab-button" onclick="openTab(event, 'guild-{{guild_data.guild_id}}')" {% if loop.first %}id="defaultOpen"{% endif %}>
|
||||
<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 %}
|
||||
@@ -114,127 +261,176 @@
|
||||
{% endif %}
|
||||
|
||||
{% for guild_data in roles %}
|
||||
<div id="guild-{{guild_data.guild_id}}" class="tab-content" {% if not loop.first %}style="display: none;"{% endif %}>
|
||||
<div style="max-height: 300px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; border-radius: 5px;">
|
||||
<div id="guild-{{guild_data.guild_id}}" class="tab-content {% if not loop.first %}hidden{% endif %}">
|
||||
<div class="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-lg p-3 space-y-2">
|
||||
{% for role in guild_data.roles %}
|
||||
<label style="display: block; margin: 5px 0;">
|
||||
<input type="checkbox" name="moderation_staff_role_ids" value="{{role.id}}" {% if role.id|string in selected_roles %}checked="checked"{% endif %}>
|
||||
<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>○</span>
|
||||
<span class="text-gray-400">○</span>
|
||||
{% endif %}
|
||||
{{role.name}}
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{role.name}}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Sélectionnez les rôles qui peuvent utiliser les commandes de modération</p>
|
||||
</div>
|
||||
|
||||
<small>Sélectionnez un ou plusieurs rôles qui peuvent utiliser les commandes de modération</small>
|
||||
<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>
|
||||
|
||||
<script>
|
||||
function openTab(evt, tabName) {
|
||||
var i, tabcontent, tabbuttons;
|
||||
tabcontent = document.getElementsByClassName("tab-content");
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].style.display = "none";
|
||||
}
|
||||
tabbuttons = document.getElementsByClassName("tab-button");
|
||||
for (i = 0; i < tabbuttons.length; i++) {
|
||||
tabbuttons[i].className = tabbuttons[i].className.replace(" active", "");
|
||||
}
|
||||
document.getElementById(tabName).style.display = "block";
|
||||
evt.currentTarget.className += " active";
|
||||
}
|
||||
document.getElementById("defaultOpen")?.click();
|
||||
</script>
|
||||
<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>
|
||||
|
||||
<style>
|
||||
.tabs {
|
||||
overflow: hidden;
|
||||
border-bottom: 2px solid #ccc;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tab-button {
|
||||
background-color: #f1f1f1;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 20px;
|
||||
transition: 0.3s;
|
||||
font-size: 14px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.tab-button:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
.tab-button.active {
|
||||
background-color: #ccc;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tab-content {
|
||||
animation: fadeEffect 0.3s;
|
||||
}
|
||||
@keyframes fadeEffect {
|
||||
from {opacity: 0;}
|
||||
to {opacity: 1;}
|
||||
}
|
||||
</style>
|
||||
<form action="{{ url_for('publishRulesMessage') }}" method="POST" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">Envoie ou remplace le message du règlement sur Discord (utilise la config <strong>enregistrée</strong> ci-dessus).</p>
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-teal-600 hover:bg-teal-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Publier le message règlement sur Discord
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<label for="moderation_embed_delete_delay">Délai de suppression des embeds (en secondes)</label>
|
||||
<input name="moderation_embed_delete_delay" type="number" value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" placeholder="0" min="0" />
|
||||
<small>Mettre 0 pour ne pas supprimer automatiquement</small>
|
||||
</fieldset>
|
||||
<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>
|
||||
|
||||
<input type="Submit" value="Enregistrer la configuration Discord">
|
||||
</form>
|
||||
<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 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>
|
||||
|
||||
<h2>API Twitch</h2>
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
<label for="twitch_client_id">Client ID</label>
|
||||
<input name="twitch_client_id" type="text" value="{{ configuration.getValue('twitch_client_id') }}" />
|
||||
<label for="twitch_client_secret">Client Secret</label>
|
||||
<input name="twitch_client_secret" type="text" value="{{ configuration.getValue('twitch_client_secret') }}" />
|
||||
<label for="twitch_channel">Chaîne à rejoindre</label>
|
||||
<input name="twitch_channel" type="text" value="{{ configuration.getValue('twitch_channel') }}"
|
||||
placeholder="#machinTruc" />
|
||||
<input type="Submit" value="Enregistrer la configuration Twitch">
|
||||
<p>
|
||||
<a href="{{ url_for('twitchConfigurationHelp') }}">Aide</a>
|
||||
</p>
|
||||
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
|
||||
<p>
|
||||
<a href="{{ url_for('twitchRequestToken') }}">Obtenir token et refresh token</a>
|
||||
</p>
|
||||
<label for="twitch_access_token">Access Token</label>
|
||||
<input name="twitch_access_token" type="text" value="{{ configuration.getValue('twitch_access_token') }}"
|
||||
readonly="readonly" />
|
||||
<label for="twitch_refresh_token">Refresh Token</label>
|
||||
<input name="twitch_refresh_token" type="text" value="{{ configuration.getValue('twitch_refresh_token') }}"
|
||||
readonly="readonly" />
|
||||
<p>Nécessite un redémarrage après l'obtention des Tokens.</p>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<a href="{{ url_for('twitchRequestToken') }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded-lg hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors text-sm font-medium">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path></svg>
|
||||
Obtenir token et refresh token
|
||||
</a>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Access Token</label>
|
||||
<input type="text" readonly
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||
value="{{ configuration.getValue('twitch_access_token') }}"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Refresh Token</label>
|
||||
<input type="text" readonly
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||
value="{{ configuration.getValue('twitch_refresh_token') }}"/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">Nécessite un redémarrage après l'obtention des Tokens.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2>Humble Bundle</h2>
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
<p>Humble Bundle propose régulièrement des bundles de jeux vidéo à des prix réduits. Activez les notifications pour recevoir automatiquement les nouveaux packs disponibles sur votre serveur Discord.</p>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<svg class="w-6 h-6 text-orange-500" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Humble Bundle</h2>
|
||||
</div>
|
||||
|
||||
<label for="humble_bundle_enable">
|
||||
<input type="checkbox" name="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked="checked"{% endif %}>
|
||||
Activer les notifications Humble Bundle
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-4">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Humble Bundle propose régulièrement des bundles de jeux vidéo à des prix réduits. Activez les notifications pour recevoir automatiquement les nouveaux packs disponibles sur votre serveur Discord.
|
||||
</p>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer les notifications Humble Bundle</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label for="humble_bundle_channel">Canal de notification</label>
|
||||
<select name="humble_bundle_channel">
|
||||
<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="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>
|
||||
|
||||
<input type="Submit" value="Enregistrer la configuration Humble Bundle">
|
||||
</form>
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-orange-600 hover:bg-orange-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Enregistrer la configuration Humble Bundle
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openTab(evt, tabName) {
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||
document.querySelectorAll('.tab-button').forEach(el => {
|
||||
el.classList.remove('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
|
||||
el.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
|
||||
});
|
||||
document.getElementById(tabName).classList.remove('hidden');
|
||||
evt.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
|
||||
evt.currentTarget.classList.add('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
|
||||
}
|
||||
document.getElementById("defaultOpen")?.click();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,224 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">FreeLoot — Jeux gratuits</h1>
|
||||
{% if request.args.get('msg') %}
|
||||
{% set msg_type = request.args.get('type') %}
|
||||
<div class="mb-4 p-4 rounded-lg {% if msg_type == 'success' %}bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300{% elif msg_type == 'error' %}bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300{% else %}bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300{% endif %}">
|
||||
{{ request.args.get('msg') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Notifications des jeux gratuits (Epic Games, Amazon Prime, GOG, Steam, Google Play, Apple App Store) via les flux
|
||||
<a href="https://feed.eikowagenknecht.com/lootscraper.xml" target="_blank" rel="noopener" class="text-amber-700 dark:text-amber-300 hover:underline">LootScraper</a>.
|
||||
Choisissez le canal Discord et les types de loot à notifier (PC, Android, iOS selon la source). Le bot vérifie le flux environ toutes les 30 minutes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<span class="text-3xl">🎁</span>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Configuration FreeLoot</h2>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('updateFreeLoot') }}" method="POST" class="space-y-6">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="freeloot_enable" {% if configuration.getValue('freeloot_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer les notifications FreeLoot</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="freeloot_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal Discord pour les notifications</label>
|
||||
<select name="freeloot_channel_id" id="freeloot_channel_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-amber-500 focus:border-transparent transition-all">
|
||||
<option value="">— Choisir un canal —</option>
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.id }}" {% if configuration.getIntValue('freeloot_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Mentions (optionnel)</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Choisissez qui mentionner au début du message (avant l’embed).</p>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="freeloot_mention_everyone" {% if mention_everyone %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">@everyone</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="freeloot_mention_here" {% if mention_here %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">@here</span>
|
||||
</label>
|
||||
</div>
|
||||
{% if roles %}
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôles à mentionner</p>
|
||||
{% if roles|length > 1 %}
|
||||
<div class="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-600 mb-3">
|
||||
{% for guild_data in roles %}
|
||||
<button type="button" class="freeloot-role-tab px-4 py-2 text-sm font-medium rounded-t-lg transition-colors {% if loop.first %}bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}"
|
||||
data-tab="freeloot-roles-{{ guild_data.guild_id }}" {% if loop.first %}data-default{% endif %}>
|
||||
{{ guild_data.guild_name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for guild_data in roles %}
|
||||
<div id="freeloot-roles-{{ guild_data.guild_id }}" class="freeloot-role-panel {% if not loop.first %}hidden{% endif %} max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-lg p-3 space-y-2">
|
||||
{% for role in guild_data.roles %}
|
||||
<label class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600/50 p-1 rounded">
|
||||
<input type="checkbox" name="freeloot_mention_roles" value="{{ role.id }}"
|
||||
{% if role.id|string in mention_role_ids %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
|
||||
{% if role.color is defined and role.color is not none and role.color.value != 0 %}
|
||||
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color:#{{ '%06x'|format(role.color.value) }}"></span>
|
||||
{% else %}
|
||||
<span class="w-3 h-3 rounded-full flex-shrink-0 bg-gray-400"></span>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ role.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Types de loot à notifier</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Cochez les sources et plateformes pour lesquelles vous voulez recevoir une notification.</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{% for key, label, emoji in sources %}
|
||||
<label class="flex items-center gap-2 cursor-pointer p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600/50 transition-colors">
|
||||
<input type="checkbox" name="freeloot_sources" value="{{ key }}"
|
||||
{% if not enabled_sources or key in enabled_sources %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
|
||||
<span class="text-lg" title="{{ label }}">{{ emoji }}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Enregistrer
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-3">Aperçu de l’embed Discord (style DraftBot)</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Exemple du message envoyé dans le canal.</p>
|
||||
<div class="inline-block rounded-r-lg overflow-hidden border border-gray-300 dark:border-gray-600 bg-[#2f3136] max-w-lg shadow-lg" style="border-left: 4px solid #E67E22;">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-2 mb-2">
|
||||
<a href="#" class="text-[#00a8fc] hover:underline font-semibold text-base flex-1">Definitely Not Fried Chicken gratuit sur l'Epic Games Store !</a>
|
||||
<img src="https://store.epicgames.com/favicon.ico" alt="" class="w-10 h-10 rounded flex-shrink-0" title="Logo boutique">
|
||||
</div>
|
||||
<p class="text-[#dcddde] text-sm leading-relaxed mb-3">Definitely Not Fried Chicken is a business management sim with a twist! Grow your drugs trade through legitimate fronts, managing both sides of the business. Acquire new "businesses", meet new clientele, develop more potent narcotics…</p>
|
||||
<div class="text-sm mb-2">
|
||||
<span class="text-[#b9bbbe]">Prix</span>
|
||||
<p class="text-[#dcddde]"><strong>Gratuit</strong> • jusqu'au 05/02/2026</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm mb-2">
|
||||
<div><span class="text-[#b9bbbe]">Prix recommandé</span><p class="text-[#dcddde]">39.99 EUR</p></div>
|
||||
<div><span class="text-[#b9bbbe]">Genres</span><p class="text-[#dcddde]">Simulation, Indie</p></div>
|
||||
<div><span class="text-[#b9bbbe]">Ratings</span><p class="text-[#dcddde]">PEGI 18, USK 18</p></div>
|
||||
</div>
|
||||
<p class="mb-3"><a href="#" class="text-[#00a8fc] hover:underline text-sm">Ouvrir dans la boutique !</a></p>
|
||||
<div class="rounded overflow-hidden bg-[#202225] aspect-video flex items-center justify-center my-2">
|
||||
<span class="text-4xl text-gray-500">🎁</span>
|
||||
</div>
|
||||
<p class="text-xs text-[#72767d] pt-1">MamieHenriette • FreeLoot</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.freeloot-role-tab').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
var tabId = this.getAttribute('data-tab');
|
||||
document.querySelectorAll('.freeloot-role-panel').forEach(p => p.classList.add('hidden'));
|
||||
document.querySelectorAll('.freeloot-role-tab').forEach(b => {
|
||||
b.classList.remove('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
|
||||
b.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
|
||||
});
|
||||
document.getElementById(tabId).classList.remove('hidden');
|
||||
this.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
|
||||
this.classList.add('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
|
||||
});
|
||||
});
|
||||
document.querySelector('.freeloot-role-tab[data-default]')?.click();
|
||||
</script>
|
||||
|
||||
{% if entries %}
|
||||
<div class="mt-8">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Jeux gratuits actuellement disponibles</h2>
|
||||
</div>
|
||||
<div class="p-6 overflow-x-auto">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for e in entries %}
|
||||
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md transition-shadow flex flex-col">
|
||||
<div class="aspect-video bg-gray-100 dark:bg-gray-700 flex items-center justify-center overflow-hidden">
|
||||
{% if e.image_url %}
|
||||
<img src="{{ e.image_url }}" alt="" class="w-full h-full object-cover" loading="lazy" />
|
||||
{% else %}
|
||||
<span class="text-4xl text-gray-400 dark:text-gray-500">🎁</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="p-4 flex flex-col flex-1 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-1 line-clamp-2" title="{{ e.game_name }}">{{ e.game_name }}</h3>
|
||||
<p class="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
<span>{{ e.emoji }}</span>
|
||||
<span>{{ e.source_label }}</span>
|
||||
</p>
|
||||
{% if e.recommended_price or e.genres or e.rating %}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-0.5 mb-2">
|
||||
{% if e.recommended_price %}<p><span class="text-gray-500 dark:text-gray-500">Prix recommandé:</span> {{ e.recommended_price }}</p>{% endif %}
|
||||
{% if e.genres %}<p><span class="text-gray-500 dark:text-gray-500">Genres:</span> {{ e.genres }}</p>{% endif %}
|
||||
{% if e.rating %}<p><span class="text-gray-500 dark:text-gray-500">Ratings:</span> {{ e.rating }}</p>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if e.updated_formatted %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-3">{{ e.updated_formatted }}</p>
|
||||
{% endif %}
|
||||
<div class="mt-auto flex flex-col gap-2">
|
||||
<form action="{{ url_for('send_free_loot_to_discord') }}" method="POST" class="w-full">
|
||||
<input type="hidden" name="entry_id" value="{{ e.id }}">
|
||||
<button type="submit" class="w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>
|
||||
Envoyer sur Discord
|
||||
</button>
|
||||
</form>
|
||||
{% if e.link %}
|
||||
<a href="{{ e.link }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center gap-2 w-full px-3 py-2 text-sm font-medium rounded-lg bg-amber-600 hover:bg-amber-700 text-white transition-colors focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Ouvrir dans la boutique
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mt-8 p-4 rounded-lg bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Le flux LootScraper n’a pas pu être chargé. Réessayez plus tard.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,29 +1,69 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Humeurs de Mamie</h1>
|
||||
<p>Définissez les statuts Discord qui changeront automatiquement toutes les 10 minutes pour donner de la personnalité à votre bot.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<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>
|
||||
|
||||
<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>Texte</th>
|
||||
<th>Action</th>
|
||||
<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>
|
||||
<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>{{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>
|
||||
<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>
|
||||
</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>
|
||||
<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 %}
|
||||
@@ -1,7 +1,102 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Bienvenue sur l'interface d'administration de Mamie.</h1>
|
||||
<p>Nous devons définir ce que nous souhaitons afficher sur la page d'accueil. Peut-être l'historique des dernières
|
||||
modifications ? de la modération ?</p>
|
||||
<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>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden mb-8">
|
||||
<div class="p-4 sm:p-6 border-b border-slate-200 dark:border-slate-700 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center justify-center w-3 h-3 rounded-full {% if discord_connected %}bg-emerald-500 ring-4 ring-emerald-500/30{% else %}bg-slate-400 ring-4 ring-slate-400/30{% endif %}" title="{% if discord_connected %}Bot Discord connecté{% else %}Bot Discord déconnecté{% endif %}"></span>
|
||||
<h2 class="text-xl font-semibold text-slate-800 dark:text-white">Discord</h2>
|
||||
</div>
|
||||
<span class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{% if discord_connected %}Connecté{% else %}Déconnecté{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-4 sm:p-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Serveurs connectés</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ discord_guild_count }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Sanctions enregistrées</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ sanctions_count }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600 sm:col-span-2 lg:col-span-2 flex items-center justify-center">
|
||||
<div class="flex flex-wrap gap-3 justify-center">
|
||||
<a href="/live-alert" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Alertes Live</a>
|
||||
<a href="/youtube" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Notification YouTube</a>
|
||||
<a href="/humeurs" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Humeurs</a>
|
||||
<a href="/protondb" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">ProtonDB</a>
|
||||
<a href="/commandes" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Commandes</a>
|
||||
<a href="/moderation" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Modération</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden mb-8">
|
||||
<div class="p-4 sm:p-6 border-b border-slate-200 dark:border-slate-700 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center justify-center w-3 h-3 rounded-full {% if twitch_connected %}bg-emerald-500 ring-4 ring-emerald-500/30{% else %}bg-slate-400 ring-4 ring-slate-400/30{% endif %}" title="{% if twitch_connected %}Bot Twitch connecté{% else %}Bot Twitch déconnecté{% endif %}"></span>
|
||||
<h2 class="text-xl font-semibold text-slate-800 dark:text-white">Twitch</h2>
|
||||
</div>
|
||||
<span class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{% if twitch_connected %}Connecté{% else %}Déconnecté{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-4 sm:p-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Canal connecté</p>
|
||||
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{% if twitch_channel_name %}{{ twitch_channel_name }}{% else %}—{% endif %}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
|
||||
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">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>
|
||||
|
||||
<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.
|
||||
|
||||
<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 pass de "hors-ligne" à "en ligne" alors
|
||||
le bot le notifiera sur discord.
|
||||
Ne peu surveiller qu'au maximum 100 chaines.
|
||||
</p>
|
||||
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>
|
||||
<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>Chaine</th>
|
||||
<th>Canal</th>
|
||||
<th>Message</th>
|
||||
<th>#</th>
|
||||
<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>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% 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>
|
||||
<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="icon">🗑</a>
|
||||
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>
|
||||
</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">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
{{ 'Modifier l\'alerte' if alert else 'Ajouter une alerte de Live' }}
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<form id="live-alert-form" action="{{ url_for('submitEditLiveAlert', id = alert.id) if alert else url_for('addLiveAlert') }}" method="POST" class="space-y-6">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Configuration de base</h3>
|
||||
|
||||
<div>
|
||||
<label for="login" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Chaîne Twitch</label>
|
||||
<input name="login" id="login" type="text" maxlength="32" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
|
||||
placeholder="chainesteve"
|
||||
value="{{alert.login if alert}}"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="notify_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de notification Discord</label>
|
||||
<select name="notify_channel" id="notify_channel"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}"{% if alert and alert.notify_channel == channel.id %}
|
||||
selected="selected" {% endif %}>{{channel.name}}</option>
|
||||
<option value="{{channel.id}}" {% if alert and alert.notify_channel == channel.id %}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>
|
||||
|
||||
<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 %}
|
||||
@@ -1,110 +1,223 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Modération Discord</h1>
|
||||
<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>
|
||||
|
||||
<p>
|
||||
Historique des actions de modération effectuées sur le serveur Discord.
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Top 3 sanctions</h2>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Utilisateurs les plus sanctionnés</p>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% for row in top_sanctioned %}
|
||||
<div class="px-5 py-3 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="flex-shrink-0 w-7 h-7 rounded-full bg-slate-200 dark:bg-slate-600 flex items-center justify-center text-sm font-bold text-slate-700 dark:text-slate-300">{{ loop.index }}</span>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-slate-800 dark:text-white truncate">{{ row.username or '—' }}</span>
|
||||
<span class="block text-xs text-slate-500 dark:text-slate-400 font-mono truncate">{{ row.discord_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="flex-shrink-0 text-sm font-semibold text-slate-600 dark:text-slate-300">{{ row.count }} sanction{{ 's' if row.count > 1 else '' }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-5 py-6 text-center text-sm text-slate-500 dark:text-slate-400">Aucune sanction enregistrée</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Top 3 modérateurs</h2>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Staff ayant effectué le plus d'actions</p>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% for row in top_moderators %}
|
||||
<div class="px-5 py-3 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="flex-shrink-0 w-7 h-7 rounded-full bg-slate-200 dark:bg-slate-600 flex items-center justify-center text-sm font-bold text-slate-700 dark:text-slate-300">{{ loop.index }}</span>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-slate-800 dark:text-white truncate">{{ row.staff_name or '—' }}</span>
|
||||
<span class="block text-xs text-slate-500 dark:text-slate-400 font-mono truncate">{{ row.staff_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="flex-shrink-0 text-sm font-semibold text-slate-600 dark:text-slate-300">{{ row.count }} action{{ 's' if row.count > 1 else '' }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-5 py-6 text-center text-sm text-slate-500 dark:text-slate-400">Aucune action enregistrée</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Le bot enregistre automatiquement les avertissements, exclusions et bannissements.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Commande</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>!averto @utilisateur raison</strong><br><small>Alias : !warn, !av, !avertissement</small></td>
|
||||
<td>Avertit un utilisateur et enregistre l'avertissement dans la base de données</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>!delaverto id</strong><br><small>Alias : !removewarn, !delwarn</small></td>
|
||||
<td>Retire un avertissement en utilisant son numéro d'ID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>!warnings</strong> ou <strong>!warnings @utilisateur</strong><br><small>Alias : !listevent, !listwarn</small></td>
|
||||
<td>Affiche la liste des événements de modération (tous ou pour un utilisateur spécifique)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>!inspect @utilisateur</strong> ou <strong>!inspect id</strong></td>
|
||||
<td>Affiche des informations détaillées sur un utilisateur : création du compte, date d'arrivée, historique de modération</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>!kick @utilisateur raison</strong></td>
|
||||
<td>Expulse un utilisateur du serveur</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>!ban @utilisateur raison</strong></td>
|
||||
<td>Bannit définitivement un utilisateur du serveur</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>!unban discord_id</strong> ou <strong>!unban #sanction_id raison</strong></td>
|
||||
<td>Révoque le bannissement d'un utilisateur et lui envoie une invitation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>!banlist</strong></td>
|
||||
<td>Affiche la liste des utilisateurs actuellement bannis du serveur</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>!aide</strong><br><small>Alias : !help</small></td>
|
||||
<td>Affiche l'aide avec toutes les commandes disponibles</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</p>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
|
||||
<details class="group">
|
||||
<summary class="flex items-center justify-between px-5 py-4 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
|
||||
<span class="font-medium text-slate-800 dark:text-white">Commandes de modération disponibles</span>
|
||||
<svg class="w-5 h-5 text-slate-400 group-open:rotate-180 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
|
||||
</summary>
|
||||
<div class="border-t border-slate-200 dark:border-slate-700">
|
||||
<div class="divide-y divide-slate-200 dark:divide-slate-700 text-sm">
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!averto @user raison</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Avertit un utilisateur</span>
|
||||
</div>
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!delaverto id</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Retire un avertissement</span>
|
||||
</div>
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!warnings [@user]</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Liste les événements de modération</span>
|
||||
</div>
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!inspect @user</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Informations sur un utilisateur</span>
|
||||
</div>
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!kick @user raison</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Expulse un utilisateur</span>
|
||||
</div>
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!ban @user raison</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Bannit un utilisateur</span>
|
||||
</div>
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!unban id</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Révoque un bannissement</span>
|
||||
</div>
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!banlist</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Liste des utilisateurs bannis</span>
|
||||
</div>
|
||||
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
|
||||
<code class="text-slate-700 dark:text-slate-300 font-mono">!transfert #canal message_id</code>
|
||||
<span class="text-slate-500 dark:text-slate-400">Transfère un message vers un autre canal (textuel, thread ou forum)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{% if not event %}
|
||||
<h2>Événements de modération</h2>
|
||||
<table class="moderation">
|
||||
<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>
|
||||
<th>Type</th>
|
||||
<th>Utilisateur</th>
|
||||
<th>Discord ID</th>
|
||||
<th>Date & Heure</th>
|
||||
<th>Raison</th>
|
||||
<th>Staff</th>
|
||||
<th>#</th>
|
||||
<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>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{% for mod_event in events %}
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
{% if mod_event.type == 'ban' %}
|
||||
<span class="text-xs font-medium text-red-600 dark:text-red-400">Ban</span>
|
||||
{% elif mod_event.type == 'kick' %}
|
||||
<span class="text-xs font-medium text-orange-600 dark:text-orange-400">Kick</span>
|
||||
{% elif mod_event.type == 'warn' or mod_event.type == 'warning' %}
|
||||
<span class="text-xs font-medium text-yellow-600 dark:text-yellow-400">Warn</span>
|
||||
{% elif mod_event.type == 'unban' %}
|
||||
<span class="text-xs font-medium text-green-600 dark:text-green-400">Unban</span>
|
||||
{% elif mod_event.type == 'transfer' %}
|
||||
<span class="text-xs font-medium text-blue-600 dark:text-blue-400">Transfert</span>
|
||||
{% elif mod_event.type == 'timeout' %}
|
||||
<span class="text-xs font-medium text-purple-600 dark:text-purple-400">Timeout</span>
|
||||
{% else %}
|
||||
<span class="text-xs font-medium text-slate-600 dark:text-slate-400">{{ mod_event.type }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-slate-800 dark:text-white">{{ mod_event.username }}</span>
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400 font-mono">{{ mod_event.discord_id }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400 whitespace-nowrap">
|
||||
{{ mod_event.created_at.strftime('%d/%m/%Y %H:%M') if mod_event.created_at else 'N/A' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400 max-w-xs">
|
||||
<div class="line-clamp-2">{{ mod_event.reason }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400">
|
||||
{{ mod_event.staff_name }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a href="{{ url_for('open_edit_moderation_event', event_id = mod_event.id) }}" class="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors">
|
||||
Modifier
|
||||
</a>
|
||||
<a href="{{ url_for('delete_moderation_event', event_id = mod_event.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')" class="text-sm text-slate-500 hover:text-red-600 dark:hover:text-red-400 transition-colors">
|
||||
Supprimer
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td>{{ mod_event.type }}</td>
|
||||
<td>{{ mod_event.username }}</td>
|
||||
<td>{{ mod_event.discord_id }}</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">✐</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">🗑</a>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-5">
|
||||
<h2 class="text-lg font-medium text-slate-800 dark:text-white mb-5">Modifier l'événement</h2>
|
||||
|
||||
<form action="{{ url_for('update_moderation_event', event_id = event.id) }}" method="POST" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Type</label>
|
||||
<input type="text" value="{{ event.type }}" disabled class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 cursor-not-allowed">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Staff</label>
|
||||
<input type="text" value="{{ event.staff_name }}" disabled class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 cursor-not-allowed">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Utilisateur</label>
|
||||
<input type="text" value="{{ event.username }}" disabled class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 cursor-not-allowed">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Discord ID</label>
|
||||
<input type="text" value="{{ event.discord_id }}" disabled class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 cursor-not-allowed font-mono">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="reason" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Raison</label>
|
||||
<input type="text" name="reason" id="reason" value="{{ event.reason }}" required class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<a href="{{ url_for('moderation') }}" class="px-4 py-2 text-slate-700 dark:text-slate-300 text-sm font-medium rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
|
||||
Annuler
|
||||
</a>
|
||||
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Patreon — Notifications de posts</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-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Notifications des nouveaux posts Patreon via le flux RSS public. Renseignez le nom du créateur Patreon
|
||||
et choisissez le canal Discord de destination. Le bot vérifie le flux environ toutes les 10 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">
|
||||
<svg class="w-8 h-8 text-orange-500" fill="currentColor" viewBox="0 0 24 24"><path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Configuration Patreon</h2>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('updatePatreon') }}" 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="patreon_enable" {% if configuration.getValue('patreon_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 Patreon</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="patreon_creator" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom du créateur Patreon</label>
|
||||
<input type="text" name="patreon_creator" id="patreon_creator"
|
||||
value="{{ configuration.getValue('patreon_creator') or '' }}"
|
||||
placeholder="ex: nom_du_createur"
|
||||
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">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Le nom tel qu'il apparaît dans l'URL : patreon.com/<strong>nom_du_createur</strong></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="patreon_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="patreon_channel_id" id="patreon_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-orange-500 focus:border-transparent transition-all">
|
||||
<option value="">— Choisir un canal —</option>
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.id }}" {% if configuration.getIntValue('patreon_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="patreon_mention_everyone" {% if mention_everyone %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-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="patreon_mention_here" {% if mention_here %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-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="patreon-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="patreon-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="patreon-roles-{{ guild_data.guild_id }}" class="patreon-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="patreon_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-orange-600 focus:ring-orange-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>
|
||||
|
||||
<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
|
||||
</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</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Exemple du message envoyé dans le canal lors d'un nouveau post Patreon.</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 #F96854;">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<img src="https://c5.patreon.com/external/favicon/favicon-32x32.png" alt="" class="w-6 h-6 rounded-full">
|
||||
<span class="text-[#dcddde] text-sm font-medium">Nom du créateur</span>
|
||||
</div>
|
||||
<a href="#" class="text-[#00a8fc] hover:underline font-semibold text-base block mb-2">Titre du post Patreon</a>
|
||||
<p class="text-[#dcddde] text-sm leading-relaxed mb-3">Ceci est un aperçu de la description du post Patreon. Le contenu HTML est automatiquement nettoyé et tronqué pour l'embed Discord...</p>
|
||||
<div class="rounded overflow-hidden bg-[#202225] aspect-video flex items-center justify-center my-2">
|
||||
<svg class="w-12 h-12 text-gray-500" fill="currentColor" viewBox="0 0 24 24"><path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
|
||||
</div>
|
||||
<p class="text-xs text-[#72767d] pt-1">MamieHenriette • Patreon</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if posts %}
|
||||
<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">Historique des posts Patreon</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ posts|length }} post{{ 's' if posts|length > 1 else '' }} enregistré{{ 's' if posts|length > 1 else '' }}</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for post in posts %}
|
||||
<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="p-4 flex flex-col flex-1">
|
||||
<div class="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white line-clamp-2 flex-1" title="{{ post.title or 'Sans titre' }}">
|
||||
{% if post.link %}
|
||||
<a href="{{ post.link }}" target="_blank" rel="noopener noreferrer" class="hover:text-orange-600 dark:hover:text-orange-400 transition-colors">{{ post.title or 'Sans titre' }}</a>
|
||||
{% else %}
|
||||
{{ post.title or 'Sans titre' }}
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% if post.notified %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 flex-shrink-0">
|
||||
<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="M5 13l4 4L19 7"></path></svg>
|
||||
Notifié
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||||
<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="M12 8v4m0 4h.01"></path></svg>
|
||||
Non notifié
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if post.description %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-3">{{ post.description|striptags|truncate(150) }}</p>
|
||||
{% endif %}
|
||||
{% if post.published_formatted %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-3">{{ post.published_formatted }}</p>
|
||||
{% endif %}
|
||||
<div class="mt-auto">
|
||||
<form action="{{ url_for('sendPatreonToDiscord') }}" method="POST" class="w-full">
|
||||
<input type="hidden" name="guid" value="{{ post.guid }}">
|
||||
<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>
|
||||
{% if post.notified %}Re-notifier{% else %}Envoyer sur Discord{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</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">Aucun post Patreon enregistré. Les posts apparaîtront ici après la première vérification du flux RSS.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.patreon-role-tab').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
var tabId = this.getAttribute('data-tab');
|
||||
document.querySelectorAll('.patreon-role-panel').forEach(p => p.classList.add('hidden'));
|
||||
document.querySelectorAll('.patreon-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('.patreon-role-tab[data-default]')?.click();
|
||||
</script>
|
||||
{% endblock %}
|
||||
+153
-42
@@ -1,61 +1,172 @@
|
||||
{% 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>
|
||||
{% if configuration.getValue('proton_db_enable_enable') or configuration.getValue('proton_db_twitch_enable') %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Alias de jeux</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th>Alias</th>
|
||||
<th>Game</th>
|
||||
<th>#</th>
|
||||
<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>
|
||||
<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>{{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>
|
||||
<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>
|
||||
</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>.
|
||||
<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>
|
||||
</form>
|
||||
</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>
|
||||
<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 sur Discord (<code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">!pdb</code>)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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_twitch_enable" id="proton_db_twitch_enable"
|
||||
{% if configuration.getValue('proton_db_twitch_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_twitch_enable" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
Activer la commande ProtonDB sur Twitch (<code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">!pdb</code>)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="proton_db_twitch_permission" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Permission minimale pour <code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">!pdb</code> sur Twitch
|
||||
</label>
|
||||
<select name="proton_db_twitch_permission" id="proton_db_twitch_permission"
|
||||
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">
|
||||
{% set current_perm = configuration.getValue('proton_db_twitch_permission') or 'viewer' %}
|
||||
<option value="viewer" {% if current_perm == 'viewer' %}selected{% endif %}>👁️ Viewer (tout le monde)</option>
|
||||
<option value="sub" {% if current_perm == 'sub' %}selected{% endif %}>⭐ Abonné (Sub)</option>
|
||||
<option value="vip" {% if current_perm == 'vip' %}selected{% endif %}>💎 VIP</option>
|
||||
<option value="moderator" {% if current_perm == 'moderator' %}selected{% endif %}>🛡️ Modérateur</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="proton_db_twitch_cooldown" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Cooldown entre deux <code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">!pdb</code> (secondes)
|
||||
</label>
|
||||
<input type="number" name="proton_db_twitch_cooldown" id="proton_db_twitch_cooldown"
|
||||
min="0" max="3600" step="1"
|
||||
value="{{ configuration.getValue('proton_db_twitch_cooldown') or 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-all"
|
||||
placeholder="0"/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">0 = pas de cooldown</p>
|
||||
</div>
|
||||
</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>
|
||||
</form>
|
||||
|
||||
</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 %}
|
||||
@@ -0,0 +1,296 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Shoutbox Modos</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #030712; }
|
||||
::-webkit-scrollbar { width: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #374151; border-radius: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-950 text-gray-100 flex flex-col h-screen">
|
||||
|
||||
<div class="flex items-center justify-between px-3 py-1.5 bg-gray-900 border-b border-gray-800 flex-shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-6a2 2 0 012-2h8z"></path></svg>
|
||||
<span class="text-sm font-semibold text-gray-200">Shoutbox Modos</span>
|
||||
<span class="text-xs text-gray-500" id="shoutboxCount"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap justify-end">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button type="button" onclick="shoutboxFontSize(-1)" class="text-gray-400 hover:text-white text-xs leading-none px-1" title="Réduire la police">A-</button>
|
||||
<span class="text-gray-500 text-xs tabular-nums min-w-[1.25rem] text-center" id="shoutboxFontLabel">14</span>
|
||||
<button type="button" onclick="shoutboxFontSize(1)" class="text-gray-400 hover:text-white text-xs leading-none px-1" title="Agrandir la police">A+</button>
|
||||
</div>
|
||||
<button type="button" onclick="toggleShoutboxSound()" id="shoutboxSoundBtn" class="text-xs flex items-center gap-1 text-green-400 hover:text-green-300" title="Son activé">
|
||||
<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="M15.536 8.464a5 5 0 010 7.072M17.95 6.05a8 8 0 010 11.9M11 5L6 9H2v6h4l5 4V5z"></path></svg>
|
||||
<span id="shoutboxSoundLabel">Son</span>
|
||||
</button>
|
||||
<div id="onlineIndicator" class="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
<span id="onlineCountText">0 en ligne</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<div class="flex-1 relative">
|
||||
<div class="absolute inset-0 overflow-y-auto font-mono p-2" id="shoutboxDisplay" style="scrollbar-width: thin;">
|
||||
<div class="text-gray-500 text-center py-4" id="shoutboxPlaceholder">Aucun message</div>
|
||||
</div>
|
||||
<button id="newMsgBtn" onclick="scrollToBottom()" class="hidden absolute bottom-2 left-1/2 -translate-x-1/2 z-10 px-3 py-1 rounded-full bg-indigo-600/90 hover:bg-indigo-500 text-white text-xs font-medium shadow-lg animate-pulse flex items-center gap-1.5 backdrop-blur-sm">
|
||||
<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="M19 14l-7 7m0 0l-7-7m7 7V3"></path></svg>
|
||||
<span id="newMsgText">Nouveaux messages</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-24 border-l border-gray-800 bg-gray-900/50 p-2 overflow-y-auto flex-shrink-0" style="scrollbar-width: thin;">
|
||||
<div class="text-xs text-gray-500 font-semibold mb-1">En ligne</div>
|
||||
<div id="shoutboxOnlineList" class="space-y-1">
|
||||
<div class="text-xs text-gray-600 italic">---</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-800 p-2 bg-gray-900 flex-shrink-0">
|
||||
<form onsubmit="sendShoutboxMessage(event)" class="flex gap-1.5">
|
||||
<span class="text-xs text-indigo-400 font-mono flex items-center">></span>
|
||||
<input type="text" id="shoutboxInput" placeholder="Message..." maxlength="500" autocomplete="off"
|
||||
class="flex-1 px-2 py-1 rounded border border-gray-700 bg-gray-800 text-gray-100 text-xs font-mono focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none">
|
||||
<button type="submit" class="px-3 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded text-xs font-medium transition-colors">Envoyer</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var knownIds = new Set();
|
||||
var lastTimestamp = '';
|
||||
var autoScroll = true;
|
||||
var tabVisible = true;
|
||||
var shoutboxAudioCtx = null;
|
||||
var unreadCount = 0;
|
||||
var shoutboxSoundEnabled = localStorage.getItem('shoutbox_sound') !== 'off';
|
||||
var shoutboxFontSizePx = parseInt(localStorage.getItem('shoutbox_fontsize'), 10);
|
||||
if (isNaN(shoutboxFontSizePx)) shoutboxFontSizePx = 14;
|
||||
|
||||
document.addEventListener('visibilitychange', function() { tabVisible = !document.hidden; });
|
||||
|
||||
function shoutboxApplyFontSize() {
|
||||
var display = document.getElementById('shoutboxDisplay');
|
||||
if (display) display.style.fontSize = shoutboxFontSizePx + 'px';
|
||||
var label = document.getElementById('shoutboxFontLabel');
|
||||
if (label) label.textContent = shoutboxFontSizePx;
|
||||
localStorage.setItem('shoutbox_fontsize', String(shoutboxFontSizePx));
|
||||
}
|
||||
|
||||
function shoutboxFontSize(delta) {
|
||||
shoutboxFontSizePx = Math.max(8, Math.min(20, shoutboxFontSizePx + delta));
|
||||
shoutboxApplyFontSize();
|
||||
}
|
||||
|
||||
function shoutboxUpdateSoundUI() {
|
||||
var btn = document.getElementById('shoutboxSoundBtn');
|
||||
if (!btn) return;
|
||||
btn.className = 'text-xs flex items-center gap-1 ' + (shoutboxSoundEnabled ? 'text-green-400 hover:text-green-300' : 'text-red-400 hover:text-red-300');
|
||||
btn.title = shoutboxSoundEnabled ? 'Son activé (nouveaux messages si fenêtre en arrière-plan)' : 'Son désactivé';
|
||||
var lab = document.getElementById('shoutboxSoundLabel');
|
||||
if (lab) lab.textContent = shoutboxSoundEnabled ? 'Son' : 'Muet';
|
||||
}
|
||||
|
||||
function toggleShoutboxSound() {
|
||||
shoutboxSoundEnabled = !shoutboxSoundEnabled;
|
||||
localStorage.setItem('shoutbox_sound', shoutboxSoundEnabled ? 'on' : 'off');
|
||||
shoutboxUpdateSoundUI();
|
||||
}
|
||||
|
||||
function shoutboxBeep() {
|
||||
if (!shoutboxSoundEnabled) return;
|
||||
try {
|
||||
if (!shoutboxAudioCtx) shoutboxAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
var osc = shoutboxAudioCtx.createOscillator();
|
||||
var gain = shoutboxAudioCtx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(shoutboxAudioCtx.destination);
|
||||
osc.frequency.value = 660;
|
||||
osc.type = 'sine';
|
||||
gain.gain.setValueAtTime(0.15, shoutboxAudioCtx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, shoutboxAudioCtx.currentTime + 0.3);
|
||||
osc.start(shoutboxAudioCtx.currentTime);
|
||||
osc.stop(shoutboxAudioCtx.currentTime + 0.3);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
var COLORS = ['#6366f1','#8b5cf6','#ec4899','#14b8a6','#f59e0b','#3b82f6','#10b981','#ef4444','#06b6d4','#84cc16'];
|
||||
var colorMap = {};
|
||||
function userColor(name) {
|
||||
if (!colorMap[name]) {
|
||||
var h = 0;
|
||||
for (var i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h);
|
||||
colorMap[name] = COLORS[Math.abs(h) % COLORS.length];
|
||||
}
|
||||
return colorMap[name];
|
||||
}
|
||||
|
||||
var currentUser = '{{ current_user.username }}';
|
||||
|
||||
function esc(t) { var d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
|
||||
|
||||
function fmtTime(iso) {
|
||||
var d = new Date(iso);
|
||||
return d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0');
|
||||
}
|
||||
|
||||
function mentionUser(username) {
|
||||
var input = document.getElementById('shoutboxInput');
|
||||
var val = input.value;
|
||||
var mention = '@' + username + ' ';
|
||||
if (val && !val.endsWith(' ')) mention = ' ' + mention;
|
||||
input.value = val + mention;
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function renderText(text) {
|
||||
var escaped = esc(text);
|
||||
var mentioned = currentUser && text.toLowerCase().indexOf('@' + currentUser.toLowerCase()) >= 0;
|
||||
escaped = escaped.replace(/@(\w+)/g, '<span class="text-indigo-400 font-semibold">@$1</span>');
|
||||
return { html: escaped, mentioned: mentioned };
|
||||
}
|
||||
|
||||
function addItem(item) {
|
||||
if (knownIds.has(item.id)) return;
|
||||
knownIds.add(item.id);
|
||||
var display = document.getElementById('shoutboxDisplay');
|
||||
var ph = document.getElementById('shoutboxPlaceholder');
|
||||
if (ph) ph.remove();
|
||||
|
||||
var line = document.createElement('div');
|
||||
line.className = 'py-0.5 leading-relaxed rounded px-1';
|
||||
var time = '<span class="text-gray-500">[' + fmtTime(item.created_at) + ']</span> ';
|
||||
|
||||
if (item.type === 'message') {
|
||||
var c = userColor(item.author);
|
||||
var rendered = renderText(item.text);
|
||||
line.innerHTML = time + '<span style="color:' + c + '" class="font-semibold cursor-pointer hover:underline" onclick="mentionUser(\'' + esc(item.author).replace(/'/g, "\\'") + '\')"><' + esc(item.author) + '></span> <span class="text-gray-200">' + rendered.html + '</span>';
|
||||
if (rendered.mentioned) {
|
||||
line.className += ' bg-red-900/40 font-bold';
|
||||
}
|
||||
} else {
|
||||
var au = (item.action || '').toUpperCase();
|
||||
var sc = 'text-red-400';
|
||||
if (['timeout','clean','permit'].indexOf(item.action) >= 0) sc = 'text-orange-400';
|
||||
if (['subon','suboff','emoteon','emoteoff','follon','folloff'].indexOf(item.action) >= 0) sc = 'text-blue-400';
|
||||
if (item.action === 'unban') sc = 'text-green-400';
|
||||
var txt = '*** ' + au;
|
||||
if (item.moderator) txt += ' par ' + item.moderator;
|
||||
if (item.target) txt += ' \u2192 ' + item.target;
|
||||
if (item.details && item.details !== '-') txt += ' (' + item.details + ')';
|
||||
txt += ' ***';
|
||||
line.innerHTML = time + '<span class="' + sc + ' font-semibold">' + esc(txt) + '</span>';
|
||||
}
|
||||
|
||||
display.appendChild(line);
|
||||
if (!tabVisible) shoutboxBeep();
|
||||
while (display.children.length > 200) display.removeChild(display.firstChild);
|
||||
if (autoScroll) {
|
||||
display.scrollTop = display.scrollHeight;
|
||||
} else {
|
||||
unreadCount++;
|
||||
var btn = document.getElementById('newMsgBtn');
|
||||
var txt = document.getElementById('newMsgText');
|
||||
if (btn && txt) {
|
||||
txt.textContent = unreadCount + ' nouveau' + (unreadCount > 1 ? 'x' : '') + ' message' + (unreadCount > 1 ? 's' : '');
|
||||
btn.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
var d = document.getElementById('shoutboxDisplay');
|
||||
d.scrollTop = d.scrollHeight;
|
||||
autoScroll = true;
|
||||
unreadCount = 0;
|
||||
var btn = document.getElementById('newMsgBtn');
|
||||
if (btn) btn.classList.add('hidden');
|
||||
}
|
||||
|
||||
function updateOnline(users) {
|
||||
var list = document.getElementById('shoutboxOnlineList');
|
||||
var countEl = document.getElementById('onlineCountText');
|
||||
if (countEl) countEl.textContent = (users ? users.length : 0) + ' en ligne';
|
||||
if (!list) return;
|
||||
if (!users || users.length === 0) { list.innerHTML = '<div class="text-xs text-gray-600 italic">---</div>'; return; }
|
||||
list.innerHTML = '';
|
||||
users.forEach(function(u) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'flex items-center gap-1.5 text-xs text-gray-300 cursor-pointer hover:text-white';
|
||||
el.onclick = function() { mentionUser(u); };
|
||||
el.innerHTML = '<span class="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0"></span>' + esc(u);
|
||||
list.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function poll() {
|
||||
var url = '{{ url_for("shoutbox_messages") }}';
|
||||
if (lastTimestamp) url += '?since=' + encodeURIComponent(lastTimestamp);
|
||||
fetch(url)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.items && data.items.length > 0) {
|
||||
data.items.forEach(addItem);
|
||||
if (autoScroll) {
|
||||
setTimeout(function() {
|
||||
var d = document.getElementById('shoutboxDisplay');
|
||||
d.scrollTop = d.scrollHeight;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
if (data.timestamp) lastTimestamp = data.timestamp;
|
||||
if (data.online_users) updateOnline(data.online_users);
|
||||
var cnt = document.getElementById('shoutboxCount');
|
||||
if (cnt) cnt.textContent = '(' + knownIds.size + ')';
|
||||
})
|
||||
.catch(function(e) { console.error('Poll error:', e); });
|
||||
}
|
||||
|
||||
function heartbeat() {
|
||||
fetch('{{ url_for("shoutbox_heartbeat") }}', { method: 'POST', headers: {'Content-Type':'application/json'}, body: '{}' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) { updateOnline(d.online_users); })
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function sendShoutboxMessage(event) {
|
||||
event.preventDefault();
|
||||
var input = document.getElementById('shoutboxInput');
|
||||
var msg = input.value.trim();
|
||||
if (!msg) return;
|
||||
var btn = event.target.querySelector('button[type="submit"]');
|
||||
btn.disabled = true;
|
||||
fetch('{{ url_for("shoutbox_send") }}', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({message: msg}) })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) { if (d.success) { input.value = ''; poll(); } })
|
||||
.catch(function() {})
|
||||
.finally(function() { btn.disabled = false; });
|
||||
}
|
||||
|
||||
document.getElementById('shoutboxDisplay').addEventListener('scroll', function() {
|
||||
autoScroll = this.scrollHeight - this.scrollTop <= this.clientHeight + 30;
|
||||
if (autoScroll && unreadCount > 0) {
|
||||
unreadCount = 0;
|
||||
var btn = document.getElementById('newMsgBtn');
|
||||
if (btn) btn.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
shoutboxApplyFontSize();
|
||||
shoutboxUpdateSoundUI();
|
||||
|
||||
setInterval(poll, 3000);
|
||||
poll();
|
||||
setInterval(heartbeat, 10000);
|
||||
heartbeat();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+327
-22
@@ -1,39 +1,344 @@
|
||||
<!DOCTYPE html>
|
||||
<html color-mode="user">
|
||||
<html lang="fr" class="h-full">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>Mamie Henriette</title>
|
||||
<link rel="stylesheet" href="/static/css/mvp.css" />
|
||||
<link rel="stylesheet" href="/static/css/style.css" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
},
|
||||
accent: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="icon" href="/static/ico/favicon.ico" type="image/x-icon">
|
||||
<link rel="shortcut icon" href="/static/ico/favicon.ico" type="image/x-icon">
|
||||
<style>
|
||||
/* Animations personnalisées */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
/* Scrollbar personnalisée */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
.dark ::-webkit-scrollbar-thumb { background: #475569; }
|
||||
.dark ::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/"><img src="/static/ico/favicon.ico"></a>
|
||||
<ul>
|
||||
<li><a href="/live-alert">Alerte live</a></li>
|
||||
<li><a href="/commandes">Commandes</a></li>
|
||||
<li><a href="/humeurs">Humeurs</a></li>
|
||||
<li><a href="/moderation">Modération</a></li>
|
||||
<li><a href="/protondb">ProtonDB</a></li>
|
||||
<li><a href="/configurations">Configurations</a></li>
|
||||
</ul>
|
||||
<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>
|
||||
<a href="{{ url_for('openPatreon') }}" 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="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
|
||||
Patreon
|
||||
</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>
|
||||
</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="{{ url_for('openPatreon') }}" 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="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
|
||||
Patreon
|
||||
</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>
|
||||
</header>
|
||||
<main>
|
||||
|
||||
<!-- 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>
|
||||
@@ -1,35 +1,92 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Procédure de configuration de Twitch</h1>
|
||||
<p>
|
||||
<strong>Avant toute chose, activez l'authentification à deux facteurs (2FA) :</strong>
|
||||
<a href="https://help.twitch.tv/s/article/two-factor-authentication?language=en_US" target="_blank">Guide officiel
|
||||
Twitch pour la 2FA</a>
|
||||
</p>
|
||||
<p>
|
||||
Rendez-vous sur <a href="https://dev.twitch.tv/console" target="_blank">la console d'applications Twitch</a> et
|
||||
ajoutez une application. Renseignez :
|
||||
<ul>
|
||||
<li>URL de redirection : {{token_redirect_url}}</li>
|
||||
<li>Catégorie : Chat Bot</li>
|
||||
</ul>
|
||||
</p>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Configuration Twitch</h1>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
Guide étape par étape pour configurer l'API Twitch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<img src="/static/img/twitch-api-01.jpg">
|
||||
<div class="space-y-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-400 text-xs font-medium">1</span>
|
||||
<h2 class="font-medium text-slate-800 dark:text-white">Activer l'authentification à deux facteurs (2FA)</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400 mb-4">
|
||||
Avant de créer une application Twitch, vous devez activer la 2FA sur votre compte.
|
||||
</p>
|
||||
<a href="https://help.twitch.tv/s/article/two-factor-authentication?language=en_US" target="_blank" class="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-white">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
|
||||
Guide officiel Twitch
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Créez le bot. Puis, de retour à la liste, éditez-le en cliquant sur Gérer. Puis cliquez sur <strong>Nouveau
|
||||
Secret</strong>. Vous trouverez ici le <strong>Client ID</strong> et le <strong>Client Secret</strong>.
|
||||
</p>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-400 text-xs font-medium">2</span>
|
||||
<h2 class="font-medium text-slate-800 dark:text-white">Créer une application Twitch</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
Rendez-vous sur la console Twitch et créez une nouvelle application :
|
||||
</p>
|
||||
<a href="https://dev.twitch.tv/console" target="_blank" class="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-800 dark:bg-slate-700 text-white text-sm rounded-lg hover:bg-slate-700 dark:hover:bg-slate-600 transition-colors">
|
||||
Console Twitch
|
||||
</a>
|
||||
|
||||
<img src="/static/img/twitch-api-02.jpg">
|
||||
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4 space-y-2 text-sm">
|
||||
<div><span class="text-slate-500 dark:text-slate-400">URL de redirection :</span> <code class="ml-1 px-1.5 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs">{{ token_redirect_url }}</code></div>
|
||||
<div><span class="text-slate-500 dark:text-slate-400">Catégorie :</span> <span class="ml-1 text-slate-800 dark:text-white">Chat Bot</span></div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Ensuite, retournez sur la page de <a href="{{url_for('openConfigurations')}}">Configuration</a>, après avoir
|
||||
enregistré le <strong>Client ID</strong> et le <strong>Client Secret</strong>, cliquez sur le lien <strong>Obtenir
|
||||
token et refresh token</strong>. Si tout se passe bien les champs <strong>Access Token</strong> et
|
||||
<strong>Refresh Token</strong> sont remplis.
|
||||
</p>
|
||||
<div class="rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
|
||||
<img src="/static/img/twitch-api-01.jpg" alt="Création d'application Twitch" class="w-full">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-400 text-xs font-medium">3</span>
|
||||
<h2 class="font-medium text-slate-800 dark:text-white">Récupérer les identifiants</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
Cliquez sur <strong class="text-slate-800 dark:text-white">Gérer</strong> puis <strong class="text-slate-800 dark:text-white">Nouveau Secret</strong> pour obtenir le Client ID et Client Secret.
|
||||
</p>
|
||||
<div class="rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
|
||||
<img src="/static/img/twitch-api-02.jpg" alt="Récupération des identifiants" class="w-full">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-400 text-xs font-medium">4</span>
|
||||
<h2 class="font-medium text-slate-800 dark:text-white">Configurer Mamie Henriette</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<ol class="list-decimal list-inside space-y-1 text-sm text-slate-600 dark:text-slate-400">
|
||||
<li>Entrez le Client ID et Client Secret</li>
|
||||
<li>Cliquez sur Enregistrer</li>
|
||||
<li>Cliquez sur "Obtenir token et refresh token"</li>
|
||||
</ol>
|
||||
<a href="{{ url_for('openConfigurations') }}" class="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-800 dark:bg-slate-700 text-white text-sm rounded-lg hover:bg-slate-700 dark:hover:bg-slate-600 transition-colors">
|
||||
Aller à la Configuration
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 %}
|
||||
@@ -0,0 +1,135 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Historique des vidéos YouTube</h1>
|
||||
<a href="{{ url_for('openYouTube') }}" class="px-4 py-2 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 text-sm 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 19l-7-7m0 0l7-7m-7 7h18"></path></svg>
|
||||
Retour
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% 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 mb-6">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Historique des vidéos détectées par le bot. Les vidéos non notifiées peuvent être envoyées manuellement sur Discord.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ total }} vidéo{{ 's' if total > 1 else '' }} au total.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-6" aria-label="Filtrer l'historique des vidéos">
|
||||
<a href="{{ url_for('youtubeHistory') }}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {{ 'bg-red-600 text-white' if history_filter == 'all' else 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300' }}">
|
||||
Toutes
|
||||
</a>
|
||||
<a href="{{ url_for('youtubeHistory', filter='video') }}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {{ 'bg-red-600 text-white' if history_filter == 'video' else 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300' }}">
|
||||
Vidéos
|
||||
</a>
|
||||
<a href="{{ url_for('youtubeHistory', filter='short') }}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {{ 'bg-red-600 text-white' if history_filter == 'short' else 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300' }}">
|
||||
Shorts
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if history %}
|
||||
<div class="space-y-4">
|
||||
{% for entry in history %}
|
||||
<div 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">
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
{% if entry.thumbnail %}
|
||||
<a href="{{ entry.url }}" target="_blank" class="shrink-0 sm:w-48 h-28 overflow-hidden bg-gray-100 dark:bg-gray-700">
|
||||
<img src="{{ entry.thumbnail }}" alt="" class="w-full h-full object-cover">
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="flex-1 p-4 flex flex-col justify-between min-w-0">
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-3 mb-1">
|
||||
<a href="{{ entry.url }}" target="_blank" class="text-base font-semibold text-gray-900 dark:text-white hover:text-red-600 dark:hover:text-red-400 transition-colors truncate">
|
||||
{{ entry.title or 'Sans titre' }}
|
||||
</a>
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
{% if entry.is_short %}
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300">Short</span>
|
||||
{% endif %}
|
||||
{% if entry.notified %}
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 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="M5 13l4 4L19 7"></path></svg>
|
||||
Notifié
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 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="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
Non notifié
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>{{ entry.channel_name or 'Inconnu' }}</span>
|
||||
{% if entry.published_at %}
|
||||
<span>{{ entry.published_at[:10] }}</span>
|
||||
{% endif %}
|
||||
{% if notification_map.get(entry.notification_id) %}
|
||||
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded font-mono">{{ notification_map[entry.notification_id].channel_id }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-3">
|
||||
<a href="{{ entry.url }}" target="_blank" class="px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
|
||||
Voir sur YouTube
|
||||
</a>
|
||||
<form action="{{ url_for('forceYouTubeNotify', history_id=entry.id) }}" method="POST" class="inline"
|
||||
onsubmit="return confirm('Envoyer la notification Discord pour cette vidéo ?')">
|
||||
<button type="submit" class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1
|
||||
{% if entry.notified %}
|
||||
text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
{% else %}
|
||||
text-white bg-red-600 hover:bg-red-700
|
||||
{% endif %}">
|
||||
<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="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>
|
||||
{{ 'Re-notifier' if entry.notified else 'Forcer la notification' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if total_pages > 1 %}
|
||||
<div class="mt-8 flex items-center justify-center gap-2">
|
||||
{% if page > 1 %}
|
||||
<a href="{{ url_for('youtubeHistory', page=page-1, filter=history_filter) }}" class="px-4 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 rounded-lg transition-colors text-sm">
|
||||
Précédent
|
||||
</a>
|
||||
{% endif %}
|
||||
<span class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Page {{ page }} / {{ total_pages }}
|
||||
</span>
|
||||
{% if page < total_pages %}
|
||||
<a href="{{ url_for('youtubeHistory', page=page+1, filter=history_filter) }}" class="px-4 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 rounded-lg transition-colors text-sm">
|
||||
Suivant
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-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>
|
||||
<p class="text-gray-500 dark:text-gray-400">Aucune vidéo détectée pour le moment. L'historique se remplira au fur et à mesure des vérifications.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,346 @@
|
||||
{% 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 flex items-center justify-between">
|
||||
<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>
|
||||
<a href="{{ url_for('youtubeHistory') }}" class="ml-4 shrink-0 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors 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 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
Historique
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not notification %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Notifications configurées</h2>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Chaîne YouTube</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Canal Discord</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for notification in notifications %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-red-600 dark:text-red-400 font-mono text-sm">{{ notification.channel_id }}</code>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-700 dark:text-gray-300">{{ notification.notify_channel_name }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full
|
||||
{% if notification.video_type == 'all' %}bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300
|
||||
{% elif notification.video_type == 'video' %}bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300
|
||||
{% else %}bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300{% endif %}">
|
||||
{% if notification.video_type == 'all' %}Toutes
|
||||
{% elif notification.video_type == 'video' %}Vidéos
|
||||
{% else %}Shorts{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-600 dark:text-gray-400 max-w-xs truncate">{{ notification.message }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<a href="{{ url_for('toggleYouTube', id = notification.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
title="{{ 'Désactiver' if notification.enable else 'Activer' }}">
|
||||
{{ '✅' if notification.enable else '❌' }}
|
||||
</a>
|
||||
<a href="{{ url_for('openEditYouTube', id = notification.id) }}"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors text-blue-600 dark:text-blue-400"
|
||||
title="Modifier">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||
</a>
|
||||
<a href="{{ url_for('delYouTube', id = notification.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette notification ?')"
|
||||
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400"
|
||||
title="Supprimer">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
Aucune notification configurée. Ajoutez-en une ci-dessous.
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
{{ 'Modifier la notification' if notification else 'Ajouter une notification YouTube' }}
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<form id="youtube-form" action="{{ url_for('submitEditYouTube', id = notification.id) if notification else url_for('addYouTube') }}" method="POST" class="space-y-6">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Configuration de base</h3>
|
||||
|
||||
<div>
|
||||
<label for="channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Lien ou ID de la chaîne YouTube</label>
|
||||
<input name="channel_id" id="channel_id" type="text" maxlength="256" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
|
||||
placeholder="https://www.youtube.com/@513v3 ou UC..."
|
||||
value="{{notification.channel_id if notification}}"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="notify_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de notification Discord</label>
|
||||
<select name="notify_channel" id="notify_channel"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if notification and notification.notify_channel == channel.id %}selected{% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="video_type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Type de vidéo à notifier</label>
|
||||
<select name="video_type" id="video_type"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all">
|
||||
<option value="all" {% if notification and notification.video_type == 'all' %}selected{% endif %}>Toutes (vidéos + shorts)</option>
|
||||
<option value="video" {% if notification and notification.video_type == 'video' %}selected{% endif %}>Vidéos uniquement</option>
|
||||
<option value="short" {% if notification and notification.video_type == 'short' %}selected{% endif %}>Shorts uniquement</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message (optionnel)</label>
|
||||
<textarea name="message" id="message" rows="2"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all resize-y"
|
||||
placeholder="Message envoyé avant l'embed">{{notification.message if notification}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Personnalisation de l'embed Discord</h3>
|
||||
|
||||
<div>
|
||||
<label for="embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre de l'embed</label>
|
||||
<input name="embed_title" id="embed_title" type="text" maxlength="256"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
|
||||
placeholder="{video_title}"
|
||||
value="{{notification.embed_title if notification}}"/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Variables: {video_title}, {channel_name}, {video_url}, {video_id}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="embed_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description de l'embed</label>
|
||||
<textarea name="embed_description" id="embed_description" rows="2"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all resize-y"
|
||||
placeholder="Description optionnelle">{{notification.embed_description if notification}}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="embed_color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Couleur</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input name="embed_color" id="embed_color" type="color"
|
||||
class="w-12 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
|
||||
value="#{{notification.embed_color if notification else 'FF0000'}}"/>
|
||||
<input type="text" id="embed_color_text" maxlength="6"
|
||||
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
value="{{notification.embed_color if notification else 'FF0000'}}" placeholder="FF0000"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="embed_author_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom de l'auteur</label>
|
||||
<input name="embed_author_name" id="embed_author_name" type="text" maxlength="256"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
|
||||
placeholder="{channel_name}"
|
||||
value="{{notification.embed_author_name if notification}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="embed_author_icon" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icône de l'auteur (URL)</label>
|
||||
<input name="embed_author_icon" id="embed_author_icon" type="text" maxlength="512"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
|
||||
placeholder="https://www.youtube.com/img/desktop/yt_1200.png"
|
||||
value="{{notification.embed_author_icon if notification}}"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="embed_footer" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Pied de page</label>
|
||||
<input name="embed_footer" id="embed_footer" type="text" maxlength="2048"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
|
||||
placeholder="Texte optionnel en bas"
|
||||
value="{{notification.embed_footer if notification}}"/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="embed_thumbnail" id="embed_thumbnail"
|
||||
{% if not notification or notification.embed_thumbnail %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-red-600 focus:ring-red-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Miniature</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="embed_image" id="embed_image"
|
||||
{% if not notification or notification.embed_image %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-red-600 focus:ring-red-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Image principale</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
{{ 'Enregistrer' if notification else 'Ajouter la notification' }}
|
||||
</button>
|
||||
{% if notification %}
|
||||
<a href="{{ url_for('openYouTube') }}"
|
||||
class="px-6 py-2.5 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 font-medium rounded-lg transition-colors">
|
||||
Annuler
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-4">Prévisualisation de l'embed Discord</h3>
|
||||
<div id="embed-preview" class="bg-[#2f3136] rounded p-4 font-sans text-[#dcddde] max-w-xl border-l-4" style="border-left-color: #FF0000;">
|
||||
<div id="embed-author" class="flex items-center mb-2 text-sm">
|
||||
<img id="embed-author-icon" src="https://www.youtube.com/img/desktop/yt_1200.png" class="w-5 h-5 rounded-full mr-2" onerror="this.style.display='none'"/>
|
||||
<span id="embed-author-name" class="font-semibold">Nom de la chaîne</span>
|
||||
</div>
|
||||
<a id="embed-title" href="#" class="text-[#00aff4] no-underline text-base font-semibold block mb-2">Titre de la vidéo</a>
|
||||
<div id="embed-description" class="text-sm leading-relaxed mb-2 text-[#dcddde]"></div>
|
||||
<div id="embed-thumbnail-container" class="my-2">
|
||||
<img id="embed-thumbnail" src="" class="max-w-[80px] max-h-[80px] rounded float-right ml-4 hidden"/>
|
||||
</div>
|
||||
<div id="embed-image-container" class="mt-4">
|
||||
<img id="embed-image" src="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" class="max-w-full rounded hidden"/>
|
||||
</div>
|
||||
<div id="embed-footer" class="mt-2 text-xs text-[#72767d]"></div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Cette prévisualisation est approximative.</p>
|
||||
|
||||
<div class="mt-6 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-gray-800 dark:text-gray-200 mb-2">Variables disponibles</h4>
|
||||
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{channel_name}</code> — Nom de la chaîne</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{video_title}</code> — Titre de la vidéo</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{video_url}</code> — Lien vers la vidéo</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{video_id}</code> — ID de la vidéo</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{thumbnail}</code> — URL de la miniature</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{published_at}</code> — Date de publication</li>
|
||||
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{is_short}</code> — True si c'est un short</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function formatText(text, vars) {
|
||||
if (!text) return '';
|
||||
return text.replace(/\{(\w+)\}/g, function(match, key) {
|
||||
return vars[key] || match;
|
||||
});
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
const embedTitle = document.getElementById('embed_title').value || '{video_title}';
|
||||
const embedDescription = document.getElementById('embed_description').value || '';
|
||||
const embedColor = document.getElementById('embed_color_text').value || 'FF0000';
|
||||
const embedAuthorName = document.getElementById('embed_author_name').value || '{channel_name}';
|
||||
const embedAuthorIcon = document.getElementById('embed_author_icon').value || 'https://www.youtube.com/img/desktop/yt_1200.png';
|
||||
const embedFooter = document.getElementById('embed_footer').value || '';
|
||||
const embedThumbnail = document.getElementById('embed_thumbnail').checked;
|
||||
const embedImage = document.getElementById('embed_image').checked;
|
||||
|
||||
const vars = {
|
||||
video_title: 'Nouvelle vidéo de test',
|
||||
channel_name: 'Ma Chaîne YouTube',
|
||||
video_url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
video_id: 'dQw4w9WgXcQ',
|
||||
thumbnail: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg',
|
||||
published_at: '2026-01-25T12:00:00Z',
|
||||
is_short: false
|
||||
};
|
||||
|
||||
document.getElementById('embed-title').textContent = formatText(embedTitle, vars);
|
||||
document.getElementById('embed-title').href = vars.video_url;
|
||||
document.getElementById('embed-description').textContent = formatText(embedDescription, vars);
|
||||
document.getElementById('embed-author-name').textContent = formatText(embedAuthorName, vars);
|
||||
document.getElementById('embed-author-icon').src = embedAuthorIcon;
|
||||
document.getElementById('embed-footer').textContent = formatText(embedFooter, vars);
|
||||
|
||||
document.getElementById('embed-preview').style.borderLeftColor = '#' + embedColor;
|
||||
|
||||
if (embedThumbnail) {
|
||||
document.getElementById('embed-thumbnail').src = vars.thumbnail;
|
||||
document.getElementById('embed-thumbnail').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('embed-thumbnail').style.display = 'none';
|
||||
}
|
||||
|
||||
if (embedImage) {
|
||||
document.getElementById('embed-image').src = vars.thumbnail;
|
||||
document.getElementById('embed-image').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('embed-image').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('embed_color').addEventListener('input', function(e) {
|
||||
document.getElementById('embed_color_text').value = e.target.value.substring(1).toUpperCase();
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
document.getElementById('embed_color_text').addEventListener('input', function(e) {
|
||||
const val = e.target.value.replace(/[^0-9A-Fa-f]/g, '').substring(0, 6);
|
||||
e.target.value = val;
|
||||
if (val.length === 6) {
|
||||
document.getElementById('embed_color').value = '#' + val;
|
||||
updatePreview();
|
||||
}
|
||||
});
|
||||
|
||||
const formFields = ['embed_title', 'embed_description', 'embed_author_name', 'embed_author_icon', 'embed_footer', 'embed_thumbnail', 'embed_image'];
|
||||
formFields.forEach(field => {
|
||||
const el = document.getElementById(field);
|
||||
if (el) {
|
||||
el.addEventListener('input', updatePreview);
|
||||
el.addEventListener('change', updatePreview);
|
||||
}
|
||||
});
|
||||
|
||||
updatePreview();
|
||||
</script>
|
||||
{% endblock %}
|
||||
+32
-11
@@ -1,6 +1,7 @@
|
||||
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.oauth import UserAuthenticator
|
||||
@@ -9,42 +10,62 @@ from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from twitchbot import USER_SCOPE
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page
|
||||
|
||||
|
||||
auth: UserAuthenticator
|
||||
|
||||
|
||||
@webapp.route("/configurations/twitch/help")
|
||||
@require_page("configurations")
|
||||
def twitchConfigurationHelp():
|
||||
return render_template("twitch-aide.html", token_redirect_url = _buildUrl())
|
||||
return render_template("twitch-aide.html", token_redirect_url=_buildUrl())
|
||||
|
||||
|
||||
@webapp.route("/configurations/twitch/request-token")
|
||||
async def twitchRequestToken():
|
||||
@require_page("configurations")
|
||||
def twitchRequestToken():
|
||||
global auth
|
||||
helper = ConfigurationHelper()
|
||||
twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
|
||||
twitch = Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
|
||||
auth = UserAuthenticator(twitch, USER_SCOPE, url=_buildUrl())
|
||||
return redirect(auth.return_auth_url())
|
||||
|
||||
|
||||
@webapp.route("/configurations/twitch/receive-token")
|
||||
async def twitchReceiveToken():
|
||||
def twitchReceiveToken():
|
||||
global auth
|
||||
state = request.args.get('state')
|
||||
code = request.args.get('code')
|
||||
if state != auth.state :
|
||||
logging('bad returned state')
|
||||
|
||||
logging.info("Callback Twitch reçu - state: %s, code: %s", state, code is not None)
|
||||
|
||||
if not hasattr(auth, 'state') or auth is None:
|
||||
logging.error('Objet auth non initialisé - veuillez réessayer')
|
||||
return redirect(url_for('openConfigurations'))
|
||||
if code == None :
|
||||
logging('no returned state')
|
||||
|
||||
if state != auth.state:
|
||||
logging.error('State invalide - attendu: %s, reçu: %s', auth.state, state)
|
||||
return redirect(url_for('openConfigurations'))
|
||||
if code is None:
|
||||
logging.error('Pas de code retourné par Twitch')
|
||||
return redirect(url_for('openConfigurations'))
|
||||
|
||||
try:
|
||||
token, refresh = await auth.authenticate(user_token=code)
|
||||
token, refresh = asyncio.run(auth.authenticate(user_token=code))
|
||||
logging.info('Tokens Twitch obtenus avec succès')
|
||||
helper = ConfigurationHelper()
|
||||
helper.createOrUpdate('twitch_access_token', token)
|
||||
helper.createOrUpdate('twitch_refresh_token', refresh)
|
||||
db.session.commit()
|
||||
logging.info('Tokens Twitch sauvegardés en base de données')
|
||||
flash('Token Twitch enregistré. Redémarrez l\'application pour que le bot utilise le nouveau token.', 'success')
|
||||
except TwitchAPIException as e:
|
||||
logging(e)
|
||||
logging.error('Erreur API Twitch: %s', e)
|
||||
flash(f'Erreur API Twitch : {e}', 'error')
|
||||
except Exception as e:
|
||||
logging.error('Erreur inattendue: %s', e)
|
||||
flash(f'Erreur inattendue : {e}', 'error')
|
||||
return redirect(url_for('openConfigurations'))
|
||||
|
||||
# hack pas fou mais on estime qu'on sera toujours en ssl en connecté
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Notifications d'événements Twitch : sub, follow, raid, clip (chat + Discord)
|
||||
from flask import render_template, request, redirect, url_for
|
||||
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.models import TwitchEventNotification
|
||||
from discordbot import bot
|
||||
|
||||
EVENT_LABELS = {
|
||||
"sub": "Abonnement (sub)",
|
||||
"follow": "Nouveau follow",
|
||||
"raid": "Raid reçu",
|
||||
"clip": "Nouveau clip",
|
||||
}
|
||||
|
||||
|
||||
@webapp.route("/twitch-events")
|
||||
@require_page("twitch_events")
|
||||
def open_twitch_events():
|
||||
configs = TwitchEventNotification.query.order_by(TwitchEventNotification.event_type).all()
|
||||
# S'assurer qu'il existe une config par type
|
||||
existing = {c.event_type for c in configs}
|
||||
for ev in ("sub", "follow", "raid", "clip"):
|
||||
if ev not in existing:
|
||||
cfg = TwitchEventNotification(
|
||||
event_type=ev,
|
||||
message_twitch="Merci {user} !" if ev != "raid" else "Bienvenue aux viewers de {from_broadcaster_name} !",
|
||||
)
|
||||
db.session.add(cfg)
|
||||
configs.append(cfg)
|
||||
db.session.commit()
|
||||
channels = bot.getAllTextChannel()
|
||||
# Nom du canal Discord pour l'affichage
|
||||
for c in configs:
|
||||
if c.discord_channel_id:
|
||||
c.discord_channel_name = next((ch.name for ch in channels if ch.id == c.discord_channel_id), None)
|
||||
else:
|
||||
c.discord_channel_name = None
|
||||
return render_template("twitch-events.html", configs=configs, channels=channels, labels=EVENT_LABELS)
|
||||
|
||||
|
||||
@webapp.route("/twitch-events/save", methods=["POST"])
|
||||
@require_page("twitch_events")
|
||||
def save_twitch_events():
|
||||
if not can_write_page("twitch_events"):
|
||||
return render_template("403.html"), 403
|
||||
for ev in ("sub", "follow", "raid", "clip"):
|
||||
cfg = TwitchEventNotification.query.filter_by(event_type=ev).first()
|
||||
if not cfg:
|
||||
cfg = TwitchEventNotification(event_type=ev)
|
||||
db.session.add(cfg)
|
||||
prefix = f"ev_{ev}_"
|
||||
cfg.enable = request.form.get(prefix + "enable") == "1"
|
||||
cfg.notify_twitch_chat = request.form.get(prefix + "notify_twitch_chat") == "1"
|
||||
cfg.notify_discord = request.form.get(prefix + "notify_discord") == "1"
|
||||
ch_id = request.form.get(prefix + "discord_channel_id")
|
||||
cfg.discord_channel_id = int(ch_id) if ch_id and ch_id.isdigit() else None
|
||||
cfg.message_twitch = (request.form.get(prefix + "message_twitch") or "").strip()[:500]
|
||||
cfg.message_discord = (request.form.get(prefix + "message_discord") or "").strip()[:2000] or None
|
||||
embed_color = (request.form.get(prefix + "embed_color") or "9146FF").strip().lstrip("#")[:6]
|
||||
cfg.embed_color = embed_color if len(embed_color) == 6 else "9146FF"
|
||||
cfg.embed_title = (request.form.get(prefix + "embed_title") or "").strip()[:256] or None
|
||||
cfg.embed_description = (request.form.get(prefix + "embed_description") or "").strip()[:2000] or None
|
||||
cfg.embed_thumbnail = request.form.get(prefix + "embed_thumbnail") == "1"
|
||||
db.session.commit()
|
||||
return redirect(url_for("open_twitch_events"))
|
||||
|
||||
|
||||
@webapp.route("/twitch-events/toggle/<event_type>")
|
||||
@require_page("twitch_events")
|
||||
def toggle_twitch_event(event_type):
|
||||
if not can_write_page("twitch_events"):
|
||||
return render_template("403.html"), 403
|
||||
if event_type not in ("sub", "follow", "raid", "clip"):
|
||||
return redirect(url_for("open_twitch_events"))
|
||||
cfg = TwitchEventNotification.query.filter_by(event_type=event_type).first()
|
||||
if cfg:
|
||||
cfg.enable = not cfg.enable
|
||||
db.session.commit()
|
||||
return redirect(url_for("open_twitch_events"))
|
||||
@@ -0,0 +1,676 @@
|
||||
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, ModShoutboxMessage
|
||||
from flask_login import current_user
|
||||
from database.helpers import ConfigurationHelper
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
def _format_stream_uptime(started_at_iso):
|
||||
"""Durée depuis le début du live (texte court pour le panneau)."""
|
||||
if not started_at_iso:
|
||||
return None
|
||||
try:
|
||||
s = str(started_at_iso).replace("Z", "+00:00")
|
||||
started = datetime.fromisoformat(s)
|
||||
if started.tzinfo is None:
|
||||
started = started.replace(tzinfo=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
sec = int((now - started).total_seconds())
|
||||
if sec < 0:
|
||||
return None
|
||||
h, sec = divmod(sec, 3600)
|
||||
m, sec = divmod(sec, 60)
|
||||
if h > 0:
|
||||
return f"{h}h {m}min"
|
||||
if m > 0:
|
||||
return f"{m} min"
|
||||
return "< 1 min"
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
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)
|
||||
stream_title = bot_status.get("twitch_stream_title", "")
|
||||
game_name = bot_status.get("twitch_game_name", "")
|
||||
started_at = bot_status.get("twitch_started_at")
|
||||
stream_uptime = _format_stream_uptime(started_at) if is_live else None
|
||||
|
||||
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,
|
||||
stream_title=stream_title,
|
||||
game_name=game_name,
|
||||
started_at=started_at,
|
||||
stream_uptime=stream_uptime,
|
||||
)
|
||||
|
||||
@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/edit/<int:cmd_id>", methods=['POST'])
|
||||
@require_page("twitch_moderation")
|
||||
def edit_twitch_commande(cmd_id):
|
||||
if not can_write_page("twitch_moderation"):
|
||||
return jsonify({"success": False, "error": "Permission refusée"}), 403
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"success": False, "error": "Données invalides"}), 400
|
||||
|
||||
commande = Commande.query.get_or_404(cmd_id)
|
||||
|
||||
trigger = (data.get('trigger') or '').strip()
|
||||
response = (data.get('response') or '').strip()
|
||||
twitch_permission = data.get('twitch_permission', commande.twitch_permission or 'viewer')
|
||||
|
||||
if not trigger or not response:
|
||||
return jsonify({"success": False, "error": "Commande et réponse requises"}), 400
|
||||
|
||||
if not trigger.startswith('!'):
|
||||
trigger = '!' + trigger
|
||||
|
||||
if twitch_permission not in TWITCH_PERMISSIONS:
|
||||
twitch_permission = 'viewer'
|
||||
|
||||
duplicate = Commande.query.filter(Commande.trigger == trigger, Commande.id != cmd_id).first()
|
||||
if duplicate:
|
||||
return jsonify({"success": False, "error": f"La commande {trigger} existe déjà"}), 409
|
||||
|
||||
commande.trigger = trigger
|
||||
commande.response = response
|
||||
commande.twitch_permission = twitch_permission
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"command": {
|
||||
"id": commande.id,
|
||||
"trigger": commande.trigger,
|
||||
"response": commande.response,
|
||||
"twitch_permission": commande.twitch_permission,
|
||||
"permission_label": TWITCH_PERMISSIONS.get(commande.twitch_permission, 'Tous'),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@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
|
||||
|
||||
try:
|
||||
if not twitchBot._loop:
|
||||
return jsonify({"success": False, "error": "Event loop du bot non disponible"}), 503
|
||||
|
||||
async def send_msg():
|
||||
await twitchBot.chat.send_message(channel, message)
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(send_msg(), twitchBot._loop)
|
||||
future.result(timeout=10)
|
||||
return jsonify({"success": True})
|
||||
except TimeoutError:
|
||||
return jsonify({"success": False, "error": "Timeout lors de l'envoi"}), 504
|
||||
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"""
|
||||
bot_status = webapp.config["BOT_STATUS"]
|
||||
clear_chat = False
|
||||
clear_reason = None
|
||||
|
||||
ended_at_raw = bot_status.get("twitch_ended_at")
|
||||
if ended_at_raw:
|
||||
try:
|
||||
ended_at = datetime.fromisoformat(ended_at_raw)
|
||||
if datetime.now(ended_at.tzinfo) >= ended_at + timedelta(hours=1):
|
||||
if bot_status.get("twitch_chat_messages"):
|
||||
bot_status["twitch_chat_messages"] = []
|
||||
bot_status["twitch_msg_timestamps"] = []
|
||||
bot_status["twitch_msg_per_minute"] = 0
|
||||
clear_chat = True
|
||||
clear_reason = "Chat vidé automatiquement 1h après la fin du live."
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
messages = list(bot_status.get("twitch_chat_messages", []))
|
||||
return jsonify({
|
||||
"messages": messages,
|
||||
"msg_per_min": int(bot_status.get("twitch_msg_per_minute", 0)),
|
||||
"clear_chat": clear_chat,
|
||||
"clear_reason": clear_reason,
|
||||
})
|
||||
|
||||
|
||||
@webapp.route("/twitch-moderation/stream-info")
|
||||
@require_page("twitch_moderation")
|
||||
def twitch_stream_info():
|
||||
"""Retourne les infos du stream en cours pour le polling dynamique."""
|
||||
bot_status = webapp.config.get("BOT_STATUS", {})
|
||||
return jsonify({
|
||||
"is_live": bot_status.get("twitch_is_live", False),
|
||||
"viewer_count": bot_status.get("twitch_viewer_count", 0),
|
||||
"title": bot_status.get("twitch_stream_title", ""),
|
||||
"game_name": bot_status.get("twitch_game_name", ""),
|
||||
"started_at": bot_status.get("twitch_started_at"),
|
||||
"msg_per_min": int(bot_status.get("twitch_msg_per_minute", 0)),
|
||||
})
|
||||
|
||||
@webapp.route("/twitch-moderation/logs/poll")
|
||||
@require_page("twitch_moderation")
|
||||
def poll_twitch_logs():
|
||||
"""Retourne les logs de modération plus récents qu'un timestamp donné."""
|
||||
since_str = request.args.get('since', '')
|
||||
since = None
|
||||
if since_str:
|
||||
try:
|
||||
since = datetime.fromisoformat(since_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
query = TwitchModerationLog.query.order_by(TwitchModerationLog.created_at.desc())
|
||||
if since:
|
||||
query = query.filter(TwitchModerationLog.created_at > since)
|
||||
logs = query.limit(20).all()
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
return jsonify({
|
||||
"logs": [
|
||||
{
|
||||
"id": log.id,
|
||||
"action": log.action,
|
||||
"moderator": log.moderator,
|
||||
"target": log.target or '-',
|
||||
"details": log.details or '-',
|
||||
"created_at": log.created_at.strftime('%d/%m %H:%M') if log.created_at else '',
|
||||
"created_at_iso": log.created_at.isoformat() if log.created_at else '',
|
||||
}
|
||||
for log in logs
|
||||
],
|
||||
"timestamp": now,
|
||||
"total": TwitchModerationLog.query.count(),
|
||||
})
|
||||
|
||||
|
||||
@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
|
||||
|
||||
admin_name = f"WebApp ({current_user.username})"
|
||||
|
||||
if not twitchBot._loop:
|
||||
return jsonify({"success": False, "error": "Event loop du bot non disponible"}), 503
|
||||
|
||||
async def execute_action():
|
||||
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))
|
||||
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", admin_name, 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", admin_name, 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.ban_user(broadcaster_id, moderator_id, user_id, reason="Purge messages", duration=1)
|
||||
_log_action("clean", admin_name, 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_message(broadcaster_id, moderator_id)
|
||||
_log_action("clean", admin_name, 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))
|
||||
|
||||
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", admin_name)
|
||||
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", admin_name)
|
||||
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", admin_name)
|
||||
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", admin_name)
|
||||
return {"success": True, "message": "Mode emote désactivé"}
|
||||
|
||||
return {"success": False, "error": f"Action '{action}' non reconnue"}
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(execute_action(), twitchBot._loop)
|
||||
result = future.result(timeout=15)
|
||||
return jsonify(result)
|
||||
except TimeoutError:
|
||||
return jsonify({"success": False, "error": "Timeout lors de l'exécution"}), 504
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.error(f"Erreur lors de l'exécution de l'action {action}: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
# =============================
|
||||
# Shoutbox modérateurs
|
||||
# =============================
|
||||
|
||||
@webapp.route("/twitch-moderation/shoutbox/send", methods=['POST'])
|
||||
@require_page("twitch_moderation")
|
||||
def shoutbox_send():
|
||||
if not can_write_page("twitch_moderation"):
|
||||
return jsonify({"success": False, "error": "Permission refusée"}), 403
|
||||
|
||||
data = request.get_json()
|
||||
message = (data.get('message') or '').strip()[:500]
|
||||
if not message:
|
||||
return jsonify({"success": False, "error": "Message vide"}), 400
|
||||
|
||||
msg = ModShoutboxMessage(
|
||||
author=current_user.username,
|
||||
message=message,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
db.session.add(msg)
|
||||
db.session.commit()
|
||||
return jsonify({"success": True, "id": msg.id})
|
||||
|
||||
|
||||
@webapp.route("/twitch-moderation/shoutbox/transfer", methods=['POST'])
|
||||
@require_page("twitch_moderation")
|
||||
def shoutbox_transfer():
|
||||
if not can_write_page("twitch_moderation"):
|
||||
return jsonify({"success": False, "error": "Permission refusée"}), 403
|
||||
|
||||
data = request.get_json() or {}
|
||||
username = (data.get('username') or '').strip().lstrip('@')
|
||||
message = (data.get('message') or '').strip()
|
||||
if not username or not message:
|
||||
return jsonify({"success": False, "error": "Données incomplètes"}), 400
|
||||
|
||||
text = f"@{username}: {message}"
|
||||
msg = ModShoutboxMessage(
|
||||
author=current_user.username,
|
||||
message=text[:500],
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
db.session.add(msg)
|
||||
db.session.commit()
|
||||
return jsonify({"success": True, "id": msg.id})
|
||||
|
||||
|
||||
@webapp.route("/twitch-moderation/shoutbox/messages")
|
||||
@require_page("twitch_moderation")
|
||||
def shoutbox_messages():
|
||||
since_str = request.args.get('since', '')
|
||||
since = None
|
||||
if since_str:
|
||||
try:
|
||||
since = datetime.fromisoformat(since_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
chat_query = ModShoutboxMessage.query
|
||||
log_query = TwitchModerationLog.query
|
||||
if since:
|
||||
chat_query = chat_query.filter(ModShoutboxMessage.created_at > since)
|
||||
log_query = log_query.filter(TwitchModerationLog.created_at > since)
|
||||
|
||||
chat_msgs = chat_query.order_by(ModShoutboxMessage.created_at.desc()).limit(100).all()
|
||||
log_msgs = log_query.order_by(TwitchModerationLog.created_at.desc()).limit(100).all()
|
||||
|
||||
items = []
|
||||
for m in chat_msgs:
|
||||
items.append({
|
||||
"type": "message",
|
||||
"id": f"msg-{m.id}",
|
||||
"author": m.author,
|
||||
"text": m.message,
|
||||
"created_at": m.created_at.isoformat() if m.created_at else '',
|
||||
})
|
||||
for log in log_msgs:
|
||||
items.append({
|
||||
"type": "sanction",
|
||||
"id": f"log-{log.id}",
|
||||
"action": log.action,
|
||||
"moderator": log.moderator,
|
||||
"target": log.target or '',
|
||||
"details": log.details or '',
|
||||
"created_at": log.created_at.isoformat() if log.created_at else '',
|
||||
})
|
||||
|
||||
items.sort(key=lambda x: x["created_at"])
|
||||
items = items[-100:]
|
||||
|
||||
return jsonify({
|
||||
"items": items,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"online_users": _get_online_users(),
|
||||
})
|
||||
|
||||
|
||||
def _get_online_users():
|
||||
heartbeats = webapp.config["BOT_STATUS"].get("shoutbox_heartbeats", {})
|
||||
cutoff = datetime.now() - timedelta(seconds=15)
|
||||
return sorted(u for u, t in heartbeats.items() if t > cutoff)
|
||||
|
||||
|
||||
@webapp.route("/twitch-moderation/shoutbox/heartbeat", methods=['POST'])
|
||||
@require_page("twitch_moderation")
|
||||
def shoutbox_heartbeat():
|
||||
hb = webapp.config["BOT_STATUS"].setdefault("shoutbox_heartbeats", {})
|
||||
hb[current_user.username] = datetime.now()
|
||||
return jsonify({"online_users": _get_online_users()})
|
||||
|
||||
|
||||
@webapp.route("/twitch-moderation/shoutbox/clear")
|
||||
@require_page("twitch_moderation")
|
||||
def shoutbox_clear():
|
||||
if not can_write_page("twitch_moderation"):
|
||||
return jsonify({"success": False, "error": "Permission refusée"}), 403
|
||||
ModShoutboxMessage.query.delete()
|
||||
db.session.commit()
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@webapp.route("/twitch-moderation/shoutbox/popout")
|
||||
@require_page("twitch_moderation")
|
||||
def shoutbox_popout():
|
||||
return render_template("shoutbox-popout.html")
|
||||
+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,253 @@
|
||||
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, YouTubeVideoHistory
|
||||
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 '').strip() 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 '').strip() 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)
|
||||
YouTubeVideoHistory.query.filter_by(notification_id=id).delete()
|
||||
db.session.delete(notification)
|
||||
db.session.commit()
|
||||
return redirect(url_for("openYouTube"))
|
||||
|
||||
|
||||
@webapp.route("/youtube/history")
|
||||
@require_page("youtube")
|
||||
def youtubeHistory():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
history_filter = request.args.get('filter', 'all')
|
||||
if history_filter not in {'all', 'video', 'short'}:
|
||||
history_filter = 'all'
|
||||
|
||||
history_query = YouTubeVideoHistory.query
|
||||
if history_filter == 'video':
|
||||
history_query = history_query.filter(YouTubeVideoHistory.is_short.is_(False))
|
||||
elif history_filter == 'short':
|
||||
history_query = history_query.filter(YouTubeVideoHistory.is_short.is_(True))
|
||||
|
||||
# published_at provient du flux YouTube (format ISO 8601), ce qui permet de
|
||||
# présenter les vidéos par date de publication, plutôt que par date de détection.
|
||||
history_query = history_query.order_by(
|
||||
YouTubeVideoHistory.published_at.desc(),
|
||||
YouTubeVideoHistory.detected_at.desc(),
|
||||
)
|
||||
total = history_query.count()
|
||||
history = history_query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
|
||||
notification_map = {}
|
||||
for entry in history:
|
||||
if entry.notification_id not in notification_map:
|
||||
notif = YouTubeNotification.query.get(entry.notification_id)
|
||||
notification_map[entry.notification_id] = notif
|
||||
|
||||
msg = request.args.get('msg')
|
||||
msg_type = request.args.get('type', 'info')
|
||||
return render_template(
|
||||
"youtube-history.html",
|
||||
history=history,
|
||||
notification_map=notification_map,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
total=total,
|
||||
history_filter=history_filter,
|
||||
msg=msg,
|
||||
msg_type=msg_type,
|
||||
)
|
||||
|
||||
|
||||
@webapp.route("/youtube/notify/<int:history_id>", methods=['POST'])
|
||||
@require_page("youtube")
|
||||
def forceYouTubeNotify(history_id):
|
||||
if not can_write_page("youtube"):
|
||||
return render_template("403.html"), 403
|
||||
from discordbot.youtube import send_video_notification_sync
|
||||
success, message = send_video_notification_sync(history_id)
|
||||
msg_type = 'success' if success else 'error'
|
||||
return redirect(url_for("youtubeHistory") + "?" + urlencode({'msg': message, 'type': msg_type}))
|
||||
Reference in New Issue
Block a user