diff --git a/database/__init__.py b/database/__init__.py index 77700d1..b35ef13 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -59,9 +59,9 @@ def _doPreImportMigration(cursor:Cursor): _renameTable('game_bundle', 'game_bundle_old', cursor) def _doPostImportMigration(cursor:Cursor): - if _tableEmpty('game_bundle', cursor) : + if _tableEmpty('game_bundle', cursor) and _tableExists('game_bundle_old', cursor): logging.info("remplir game_bundle avec game_bundle_old") - bundles = cursor.execute(f'SELECT * FROM game_bundle_old').fetchall() + bundles = cursor.execute('SELECT * FROM game_bundle_old').fetchall() for bundle in bundles : name = bundle[1] json_data = json.loads(bundle[2]) @@ -90,18 +90,209 @@ def _doPostImportMigration(cursor:Cursor): except Exception as e: logging.warning(f"Colonne youtube_notification.{col_name}: {e}") + if _tableExists('commande', cursor) and not _tableHaveColumn('commande', 'twitch_permission', cursor): + try: + cursor.execute("ALTER TABLE commande ADD COLUMN twitch_permission VARCHAR(16) DEFAULT 'viewer'") + logging.info("Colonne twitch_permission ajoutée à commande") + except Exception as e: + logging.warning(f"Colonne commande.twitch_permission: {e}") + + +def _doAddColumnMigrations(cursor: Cursor): + """Migrations d'ajout de colonnes. Exécutées à part pour ne pas dépendre du script principal.""" + if _tableExists('commande', cursor) and not _tableHaveColumn('commande', 'twitch_permission', cursor): + try: + cursor.execute("ALTER TABLE commande ADD COLUMN twitch_permission VARCHAR(16) DEFAULT 'viewer'") + logging.info("Colonne twitch_permission ajoutée à commande") + except Exception as e: + logging.warning(f"Colonne commande.twitch_permission: {e}") + if _tableExists('live_alert', cursor) and not _tableHaveColumn('live_alert', 'watch_activity', cursor): + try: + cursor.execute("ALTER TABLE live_alert ADD COLUMN watch_activity BOOLEAN NOT NULL DEFAULT 0") + logging.info("Colonne watch_activity ajoutée à live_alert") + except Exception as e: + logging.warning(f"Colonne live_alert.watch_activity: {e}") + + # Colonnes embed pour live_alert (message par défaut en embed) + if _tableExists('live_alert', cursor): + live_alert_embed_columns = [ + ('embed_title', 'VARCHAR(256)'), + ('embed_description', 'VARCHAR(2000)'), + ('embed_color', 'VARCHAR(8) DEFAULT "9146FF"'), + ('embed_footer', 'VARCHAR(2048)'), + ('embed_author_name', 'VARCHAR(256)'), + ('embed_author_icon', 'VARCHAR(512)'), + ('embed_thumbnail', 'BOOLEAN DEFAULT 1'), + ('embed_image', 'BOOLEAN DEFAULT 1'), + ] + for col_name, col_type in live_alert_embed_columns: + if not _tableHaveColumn('live_alert', col_name, cursor): + try: + cursor.execute(f'ALTER TABLE live_alert ADD COLUMN {col_name} {col_type}') + logging.info(f"Colonne {col_name} ajoutée à live_alert") + except Exception as e: + logging.warning(f"Colonne live_alert.{col_name}: {e}") + + # Table twitch_event_notification + seed des 4 types + if not _tableExists('twitch_event_notification', cursor): + try: + cursor.execute(""" + CREATE TABLE twitch_event_notification ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type VARCHAR(32) UNIQUE NOT NULL, + enable BOOLEAN NOT NULL DEFAULT 1, + notify_twitch_chat BOOLEAN NOT NULL DEFAULT 1, + notify_discord BOOLEAN NOT NULL DEFAULT 0, + discord_channel_id INTEGER NULL, + message_twitch VARCHAR(500) NOT NULL DEFAULT '', + message_discord VARCHAR(2000) NULL, + embed_color VARCHAR(8) DEFAULT '9146FF', + embed_title VARCHAR(256) NULL, + embed_description VARCHAR(2000) NULL, + embed_thumbnail BOOLEAN NOT NULL DEFAULT 1, + last_clip_id VARCHAR(128) NULL + ) + """) + logging.info("Table twitch_event_notification créée") + except Exception as e: + logging.warning(f"Table twitch_event_notification: {e}") + if _tableExists('twitch_event_notification', cursor) and _tableEmpty('twitch_event_notification', cursor): + for ev in ('sub', 'follow', 'raid', 'clip'): + try: + cursor.execute( + "INSERT INTO twitch_event_notification (event_type, message_twitch) VALUES (?, ?)", + (ev, 'Merci {user} !' if ev != 'raid' else 'Bienvenue aux viewers de {from_broadcaster_name} !'), + ) + except Exception as e: + logging.warning(f"Seed twitch_event_notification {ev}: {e}") + + # Table webapp_user (auth) + if not _tableExists('webapp_user', cursor): + try: + cursor.execute(""" + CREATE TABLE webapp_user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(64) UNIQUE NOT NULL, + email VARCHAR(256) UNIQUE NOT NULL, + password_hash VARCHAR(256) NOT NULL, + role VARCHAR(64) NOT NULL DEFAULT 'viewer_twitch', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """) + logging.info("Table webapp_user créée") + except Exception as e: + logging.warning(f"Table webapp_user: {e}") + + # Tables webapp_role et webapp_page_permission + if not _tableExists('webapp_role', cursor): + try: + cursor.execute(""" + CREATE TABLE webapp_role ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(64) UNIQUE NOT NULL, + level INTEGER NOT NULL DEFAULT 0 + ) + """) + logging.info("Table webapp_role créée") + except Exception as e: + logging.warning(f"Table webapp_role: {e}") + if not _tableExists('webapp_page_permission', cursor): + try: + cursor.execute(""" + CREATE TABLE webapp_page_permission ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_key VARCHAR(64) UNIQUE NOT NULL, + min_level INTEGER NOT NULL DEFAULT 0, + write_level INTEGER NULL + ) + """) + logging.info("Table webapp_page_permission créée") + except Exception as e: + logging.warning(f"Table webapp_page_permission: {e}") + + +def _doSeedAuth(cursor: Cursor): + """Seed rôles par défaut et permissions des pages si vides.""" + from database.models import ROLE_ORDER + if not _tableExists('webapp_role', cursor): + return + if cursor.execute("SELECT COUNT(*) FROM webapp_role").fetchone()[0] > 0: + return + default_roles = [ + ("viewer_twitch", 0), + ("utilisateur_discord", 1), + ("moderateur_discord", 2), + ("expert_discord", 3), + ("moderateur_twitch", 4), + ("super_administrateur", 5), + ] + for name, level in default_roles: + try: + cursor.execute("INSERT INTO webapp_role (name, level) VALUES (?, ?)", (name, level)) + except Exception as e: + logging.warning(f"Seed role {name}: {e}") + logging.info("Rôles par défaut insérés") + + if not _tableExists('webapp_page_permission', cursor): + return + if cursor.execute("SELECT COUNT(*) FROM webapp_page_permission").fetchone()[0] > 0: + return + # page_key, min_level, write_level (NULL = même que min_level) + default_pages = [ + ("index", 0, None), + ("configurations", 5, 5), + ("commandes", 1, 2), + ("humeurs", 1, 2), + ("live_alert", 0, 4), + ("announcements", 0, 4), + ("twitch_moderation", 0, 4), + ("link_filter", 0, 4), + ("twitch_events", 0, 4), + ("youtube", 1, 2), + ("protondb", 1, 2), + ("freeloot", 1, 2), + ("moderation", 1, 2), + ("users", 5, 5), + ("settings", 5, 5), + ] + for page_key, min_level, wl in default_pages: + try: + cursor.execute( + "INSERT INTO webapp_page_permission (page_key, min_level, write_level) VALUES (?, ?, ?)", + (page_key, min_level, wl), + ) + except Exception as e: + logging.warning(f"Seed page {page_key}: {e}") + logging.info("Permissions pages par défaut insérées") + + # Inscriptions activées par défaut + if _tableExists("configuration", cursor): + try: + cursor.execute( + "INSERT OR IGNORE INTO configuration (key, value) VALUES ('registration_enabled', 'true')" + ) + except Exception as e: + logging.warning(f"Config registration_enabled: {e}") + + with webapp.app_context(): with open('database/schema.sql', 'r') as f: sql = f.read() - connection : Connection = db.session.connection().connection + connection: Connection = db.session.connection().connection + try: + cursor = connection.cursor() + _doPreImportMigration(cursor) + cursor.executescript(sql) + _doPostImportMigration(cursor) + connection.commit() + except Exception as e: + logging.error(f"lors de l'import de la bdd : {e}") + finally: try: - cursor : Cursor = connection.cursor() - _doPreImportMigration(cursor) - cursor.executescript(sql) - _doPostImportMigration(cursor) + cursor = connection.cursor() + _doAddColumnMigrations(cursor) + _doSeedAuth(cursor) connection.commit() - cursor.close() except Exception as e: - logging.error(f"lors de l'import de la bdd : {e}") - finally: - connection.close() + logging.warning(f"migrations colonnes : {e}") + connection.close() diff --git a/database/migration_add_banned_words.sql b/database/migration_add_banned_words.sql new file mode 100644 index 0000000..f0c8b5f --- /dev/null +++ b/database/migration_add_banned_words.sql @@ -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 +); diff --git a/database/models.py b/database/models.py index 16a74d8..0234412 100644 --- a/database/models.py +++ b/database/models.py @@ -1,4 +1,62 @@ +from datetime import datetime from database import db +from flask_login import UserMixin + +# Rôles par défaut (niveau 0 à 5) — seed en base via migration +ROLE_ORDER = [ + "viewer_twitch", + "utilisateur_discord", + "moderateur_discord", + "expert_discord", + "moderateur_twitch", + "super_administrateur", +] + +def role_level(role_name: str) -> int: + """Retourne le niveau du rôle depuis la table webapp_role (-1 si inconnu).""" + if not role_name: + return -1 + r = WebappRole.query.filter_by(name=role_name).first() + return r.level if r else -1 + +class WebappRole(db.Model): + __tablename__ = "webapp_role" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(64), unique=True, nullable=False) + level = db.Column(db.Integer, nullable=False, default=0) + description = db.Column(db.String(256), nullable=True) + color = db.Column(db.String(7), nullable=True, default="#6B7280") # Couleur hexadécimale pour l'affichage + icon = db.Column(db.String(32), nullable=True) # Nom d'icône (ex: 'user', 'shield', 'crown') + +class PagePermission(db.Model): + __tablename__ = "webapp_page_permission" + id = db.Column(db.Integer, primary_key=True) + page_key = db.Column(db.String(64), unique=True, nullable=False) + min_level = db.Column(db.Integer, nullable=False, default=0) + write_level = db.Column(db.Integer, nullable=True) + category = db.Column(db.String(32), nullable=True, default="general") # Catégorie: 'general', 'moderation', 'content', 'config' + description = db.Column(db.String(256), nullable=True) # Description de la page + +class WebappUser(db.Model, UserMixin): + __tablename__ = "webapp_user" + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), unique=True, nullable=False) + email = db.Column(db.String(256), unique=True, nullable=False) + password_hash = db.Column(db.String(256), nullable=False) + role = db.Column(db.String(64), nullable=False, default="viewer_twitch") + created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + + def get_level(self) -> int: + return role_level(self.role) + + def has_role_at_least(self, min_role: str) -> bool: + return self.get_level() >= role_level(min_role) + + def has_level_at_least(self, level: int) -> bool: + return self.get_level() >= level + + def has_any_role(self, roles: list) -> bool: + return self.role in roles class Configuration(db.Model): key = db.Column(db.String(32), primary_key=True) @@ -25,7 +83,17 @@ class LiveAlert(db.Model): online = db.Column(db.Boolean, default=False) login = db.Column(db.String(128)) notify_channel = db.Column(db.Integer) - message = db.Column(db.String(2000)) + message = db.Column(db.String(2000)) # message optionnel avant l'embed + watch_activity = db.Column(db.Boolean, default=False) + # Personnalisation de l'embed Discord (comme YouTube) + embed_title = db.Column(db.String(256)) + embed_description = db.Column(db.String(2000)) + embed_color = db.Column(db.String(8), default='9146FF') # violet Twitch + embed_footer = db.Column(db.String(2048)) + embed_author_name = db.Column(db.String(256)) + embed_author_icon = db.Column(db.String(512)) + embed_thumbnail = db.Column(db.Boolean, default=True) + embed_image = db.Column(db.Boolean, default=True) class TwitchAnnouncement(db.Model): __tablename__ = 'twitch_announcement' @@ -37,12 +105,14 @@ class TwitchAnnouncement(db.Model): min_chat_messages = db.Column(db.Integer, default=0) last_sent = db.Column(db.DateTime, nullable=True) +# Niveaux de permission Twitch pour les commandes personnalisées : viewer, sub, vip, moderator class Commande(db.Model): id = db.Column(db.Integer, primary_key=True) discord_enable = db.Column(db.Boolean, default=True) twitch_enable = db.Column(db.Boolean, default=True) trigger = db.Column(db.String(32), unique=True) response = db.Column(db.String(2000)) + twitch_permission = db.Column(db.String(16), default='viewer') # viewer | sub | vip | moderator class ModerationEvent(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -55,6 +125,46 @@ class ModerationEvent(db.Model): staff_name = db.Column(db.String(256)) duration = db.Column(db.Integer) + +class TwitchModerationLog(db.Model): + __tablename__ = 'twitch_moderation_log' + id = db.Column(db.Integer, primary_key=True) + action = db.Column(db.String(32)) + moderator = db.Column(db.String(256)) + target = db.Column(db.String(256)) + details = db.Column(db.String(512)) + created_at = db.Column(db.DateTime) + + +class TwitchLinkFilter(db.Model): + __tablename__ = 'twitch_link_filter' + id = db.Column(db.Integer, primary_key=True) + enabled = db.Column(db.Boolean, default=False) + allow_subscribers = db.Column(db.Boolean, default=True) + allow_vips = db.Column(db.Boolean, default=True) + allow_moderators = db.Column(db.Boolean, default=True) + timeout_duration = db.Column(db.Integer, default=60) + warning_message = db.Column(db.String(500), default="Les liens ne sont pas autorises dans le chat.") + + +class TwitchAllowedDomain(db.Model): + __tablename__ = 'twitch_allowed_domain' + id = db.Column(db.Integer, primary_key=True) + domain = db.Column(db.String(256), unique=True) + + +class TwitchPermit(db.Model): + __tablename__ = 'twitch_permit' + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(256)) + expires_at = db.Column(db.DateTime) + + +class TwitchAllowedUser(db.Model): + __tablename__ = 'twitch_allowed_user' + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(256), unique=True) + class AntiCheatCache(db.Model): __tablename__ = 'anticheat_cache' steam_id = db.Column(db.String(32), primary_key=True) @@ -84,3 +194,36 @@ class YouTubeNotification(db.Model): embed_thumbnail = db.Column(db.Boolean, default=True) embed_image = db.Column(db.Boolean, default=True) + +class FreeLootEntry(db.Model): + __tablename__ = 'freeloot_entry' + entry_id = db.Column(db.String(256), primary_key=True) + + +class TwitchEventNotification(db.Model): + """Configuration des notifications par type d'événement (sub, follow, raid, clip).""" + __tablename__ = 'twitch_event_notification' + id = db.Column(db.Integer, primary_key=True) + event_type = db.Column(db.String(32), unique=True, nullable=False) # sub, follow, raid, clip + enable = db.Column(db.Boolean, default=True) + notify_twitch_chat = db.Column(db.Boolean, default=True) + notify_discord = db.Column(db.Boolean, default=False) + discord_channel_id = db.Column(db.Integer, nullable=True) + message_twitch = db.Column(db.String(500), default='') + message_discord = db.Column(db.String(2000), nullable=True) + embed_color = db.Column(db.String(8), default='9146FF') + embed_title = db.Column(db.String(256), nullable=True) + embed_description = db.Column(db.String(2000), nullable=True) + embed_thumbnail = db.Column(db.Boolean, default=True) + last_clip_id = db.Column(db.String(128), nullable=True) # pour détecter les nouveaux clips + + +class TwitchBannedWord(db.Model): + """Mots interdits dans le chat Twitch.""" + __tablename__ = 'twitch_banned_word' + id = db.Column(db.Integer, primary_key=True) + word = db.Column(db.String(256), unique=True, nullable=False) + enabled = db.Column(db.Boolean, default=True) + timeout_duration = db.Column(db.Integer, default=60) # durée du timeout en secondes + created_at = db.Column(db.DateTime, default=datetime.utcnow) + diff --git a/database/schema.sql b/database/schema.sql index 29a8d37..152dc93 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -28,7 +28,16 @@ CREATE TABLE IF NOT EXISTS live_alert ( `online` BOOLEAN NOT NULL DEFAULT FALSE, `login` VARCHAR(128) UNIQUE NOT NULL, `notify_channel` INTEGER NOT NULL, - `message` VARCHAR(2000) NOT NULL + `message` VARCHAR(2000), + `watch_activity` BOOLEAN NOT NULL DEFAULT FALSE, + `embed_title` VARCHAR(256), + `embed_description` VARCHAR(2000), + `embed_color` VARCHAR(8) DEFAULT '9146FF', + `embed_footer` VARCHAR(2048), + `embed_author_name` VARCHAR(256), + `embed_author_icon` VARCHAR(512), + `embed_thumbnail` BOOLEAN DEFAULT TRUE, + `embed_image` BOOLEAN DEFAULT TRUE ); CREATE TABLE IF NOT EXISTS `twitch_announcement` ( @@ -46,7 +55,8 @@ CREATE TABLE IF NOT EXISTS `commande` ( `discord_enable` BOOLEAN NOT NULL DEFAULT TRUE, `twitch_enable` BOOLEAN NOT NULL DEFAULT TRUE, `trigger` VARCHAR(16) UNIQUE NOT NULL, - `response` VARCHAR(2000) NOT NULL + `response` VARCHAR(2000) NOT NULL, + `twitch_permission` VARCHAR(16) DEFAULT 'viewer' ); CREATE TABLE IF NOT EXISTS `moderation_event` ( @@ -61,6 +71,15 @@ CREATE TABLE IF NOT EXISTS `moderation_event` ( `duration` INTEGER NULL ); +CREATE TABLE IF NOT EXISTS `twitch_moderation_log` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + `action` VARCHAR(32) NOT NULL, + `moderator` VARCHAR(256) NOT NULL, + `target` VARCHAR(256), + `details` VARCHAR(512), + `created_at` DATETIME NOT NULL +); + CREATE TABLE IF NOT EXISTS `anticheat_cache` ( steam_id VARCHAR(32) PRIMARY KEY, game_name VARCHAR(256) NOT NULL, @@ -80,6 +99,40 @@ CREATE TABLE IF NOT EXISTS `member_invites` ( `join_date` DATETIME NOT NULL ); +CREATE TABLE IF NOT EXISTS `twitch_link_filter` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + `enabled` BOOLEAN NOT NULL DEFAULT FALSE, + `allow_subscribers` BOOLEAN NOT NULL DEFAULT TRUE, + `allow_vips` BOOLEAN NOT NULL DEFAULT TRUE, + `allow_moderators` BOOLEAN NOT NULL DEFAULT TRUE, + `timeout_duration` INTEGER NOT NULL DEFAULT 60, + `warning_message` VARCHAR(500) NOT NULL DEFAULT 'Les liens ne sont pas autorises dans le chat.' +); + +CREATE TABLE IF NOT EXISTS `twitch_allowed_domain` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + `domain` VARCHAR(256) UNIQUE NOT NULL +); + +CREATE TABLE IF NOT EXISTS `twitch_permit` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + `username` VARCHAR(256) NOT NULL, + `expires_at` DATETIME NOT NULL +); + +CREATE TABLE IF NOT EXISTS `twitch_allowed_user` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + `username` VARCHAR(256) UNIQUE NOT NULL +); + +CREATE TABLE IF NOT EXISTS `twitch_banned_word` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + `word` VARCHAR(256) UNIQUE NOT NULL, + `enabled` BOOLEAN NOT NULL DEFAULT TRUE, + `timeout_duration` INTEGER NOT NULL DEFAULT 60, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE IF NOT EXISTS `youtube_notification` ( id INTEGER PRIMARY KEY AUTOINCREMENT, `enable` BOOLEAN NOT NULL DEFAULT TRUE, @@ -97,3 +150,51 @@ CREATE TABLE IF NOT EXISTS `youtube_notification` ( `embed_thumbnail` BOOLEAN NOT NULL DEFAULT TRUE, `embed_image` BOOLEAN NOT NULL DEFAULT TRUE ); + +CREATE TABLE IF NOT EXISTS `webapp_user` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(64) UNIQUE NOT NULL, + email VARCHAR(256) UNIQUE NOT NULL, + password_hash VARCHAR(256) NOT NULL, + role VARCHAR(64) NOT NULL DEFAULT 'viewer_twitch', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS `webapp_role` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(64) UNIQUE NOT NULL, + level INTEGER NOT NULL DEFAULT 0, + description VARCHAR(256) NULL, + color VARCHAR(7) NULL DEFAULT '#6B7280', + icon VARCHAR(32) NULL +); + +CREATE TABLE IF NOT EXISTS `webapp_page_permission` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_key VARCHAR(64) UNIQUE NOT NULL, + min_level INTEGER NOT NULL DEFAULT 0, + write_level INTEGER NULL, + category VARCHAR(32) NULL DEFAULT 'general', + description VARCHAR(256) NULL +); + +CREATE TABLE IF NOT EXISTS `freeloot_entry` ( + entry_id VARCHAR(256) PRIMARY KEY +); + +-- Notifications d'événements Twitch (sub, follow, raid, clip) : chat Twitch et/ou canal Discord +CREATE TABLE IF NOT EXISTS `twitch_event_notification` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type VARCHAR(32) UNIQUE NOT NULL, + `enable` BOOLEAN NOT NULL DEFAULT TRUE, + notify_twitch_chat BOOLEAN NOT NULL DEFAULT TRUE, + notify_discord BOOLEAN NOT NULL DEFAULT FALSE, + discord_channel_id INTEGER NULL, + message_twitch VARCHAR(500) NOT NULL DEFAULT '', + message_discord VARCHAR(2000) NULL, + embed_color VARCHAR(8) DEFAULT '9146FF', + embed_title VARCHAR(256) NULL, + embed_description VARCHAR(2000) NULL, + embed_thumbnail BOOLEAN NOT NULL DEFAULT TRUE, + last_clip_id VARCHAR(128) NULL +); diff --git a/discordbot/__init__.py b/discordbot/__init__.py index cffafb6..ce06bef 100644 --- a/discordbot/__init__.py +++ b/discordbot/__init__.py @@ -7,8 +7,9 @@ from webapp import webapp from database import db from database.helpers import ConfigurationHelper from database.models import Configuration, Humeur, Commande -from discord import Message, TextChannel, Member +from discord import Message, TextChannel, Member, VoiceChannel from discordbot.humblebundle import checkHumbleBundleAndNotify +from discordbot.freeloot import checkFreeLootAndNotify from discordbot.moderation import ( handle_warning_command, handle_remove_warning_command, @@ -24,6 +25,7 @@ from discordbot.moderation import ( ) from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache from discordbot.youtube import checkYouTubeVideos +from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms from protondb import searhProtonDb class DiscordBot(discord.Client): @@ -40,6 +42,7 @@ class DiscordBot(discord.Client): self.loop.create_task(self.updateStatus()) self.loop.create_task(self.updateHumbleBundle()) self.loop.create_task(self.updateYouTube()) + self.loop.create_task(self.updateFreeLoot()) async def on_disconnect(self): webapp.config["BOT_STATUS"]["discord_connected"] = False @@ -64,13 +67,25 @@ class DiscordBot(discord.Client): await checkYouTubeVideos() await asyncio.sleep(5*60) + async def updateFreeLoot(self): + while not self.is_closed(): + await checkFreeLootAndNotify(self) + await asyncio.sleep(30*60) + def getAllTextChannel(self) -> list[TextChannel]: channels = [] for channel in self.get_all_channels(): if isinstance(channel, TextChannel): channels.append(channel) return channels - + + def getAllVoiceChannels(self) -> list[VoiceChannel]: + channels = [] + for channel in self.get_all_channels(): + if isinstance(channel, VoiceChannel): + channels.append(channel) + return channels + def getAllRoles(self): guilds_roles = [] for guild in self.guilds: @@ -261,6 +276,14 @@ async def on_message(message: Message): except Exception as e: logging.error(f"Échec de l'envoi de l'embed ProtonDB : {e}") +@bot.event +async def on_voice_state_update(member: Member, before, after): + await on_voice_state_update_auto_rooms(bot, member, before, after) + +@bot.event +async def on_raw_reaction_add(payload: discord.RawReactionActionEvent): + await on_raw_reaction_add_auto_rooms(bot, payload) + @bot.event async def on_member_join(member: Member): await sendWelcomeMessage(bot, member) diff --git a/discordbot/auto_rooms.py b/discordbot/auto_rooms.py new file mode 100644 index 0000000..c74e7e1 --- /dev/null +++ b/discordbot/auto_rooms.py @@ -0,0 +1,332 @@ +# discordbot/auto_rooms.py — Auto rooms : message et réactions dans la partie texte du salon vocal (onglet Discussion) +import logging +from typing import Optional + +import discord +from discord import Member, VoiceState +from database.helpers import ConfigurationHelper + +# (guild_id, owner_id) -> room_data (voice_channel_id, control_message_id, whitelist, blacklist, access_mode) +_rooms: dict[tuple[int, int], dict] = {} + +# message_id -> (guild_id, owner_id) pour retrouver la room depuis une réaction +_control_message_ids: dict[int, tuple[int, int]] = {} + +# Emoji -> action +REACTIONS = [ + ("🔓", "open", "Ouvert"), + ("🔒", "closed", "Fermé"), + ("🛡️", "private", "Privé"), + ("✅", "whitelist", "Liste blanche"), + ("🚫", "blacklist", "Liste noire"), + ("🧹", "purge", "Purge"), + ("👑", "transfer", "Propriété"), + ("🎤", "speak", "Micro"), + ("📹", "stream", "Vidéo"), + ("📝", "status", "Statut"), +] + + +def _status_display(access_mode: str) -> str: + """Cadenas ouvert ou fermé selon si le salon est ouvert ou pas.""" + if access_mode == "open": + return "🔓 Ouvert" + if access_mode == "closed": + return "🔒 Fermé" + if access_mode == "private": + return "🔒 Privé" + return "🔓 Ouvert" + + +def _status_emoji(access_mode: str) -> str: + """Emoji cadenas seul pour le nom du channel.""" + return "🔓" if access_mode == "open" else "🔒" + + +def _build_control_embed(owner: Member, voice_channel: discord.VoiceChannel, access_mode: str) -> discord.Embed: + """Construit l’embed de config avec infos du salon.""" + embed = discord.Embed( + title="Configuration du salon", + description=( + "Voici l’espace de configuration de votre salon vocal. " + "Utilisez les réactions ci-dessous — seul le propriétaire peut réagir." + ), + color=discord.Color.blurple() + ) + members_count = len(voice_channel.members) + user_limit = voice_channel.user_limit or 0 + limit_text = f"{user_limit} max" if user_limit else "Illimitée" + members_text = f"{members_count} / {user_limit}" if user_limit else str(members_count) + bitrate_kbps = (voice_channel.bitrate or 0) // 1000 + + embed.add_field(name="Propriétaire", value=owner.mention, inline=True) + embed.add_field(name="Statut du salon", value=_status_display(access_mode), inline=True) + embed.add_field(name="Nom du salon", value=voice_channel.name, inline=True) + embed.add_field(name="Membres", value=members_text, inline=True) + embed.add_field(name="Limite", value=limit_text, inline=True) + embed.add_field(name="Bitrate", value=f"{bitrate_kbps} kbps", inline=True) + embed.add_field(name="Accès", value="🔓 Ouvert · 🔒 Fermé · 🛡️ Privé", inline=False) + embed.add_field(name="Listes", value="✅ Liste blanche · 🚫 Liste noire", inline=False) + embed.add_field(name="Actions", value="🧹 Purge · 👑 Propriété · 🎤 Micro · 📹 Vidéo · 📝 Statut", inline=False) + return embed + + +def _room_key(guild_id: int, owner_id: int) -> tuple[int, int]: + return (guild_id, owner_id) + + +def _get_room(guild_id: int, owner_id: int) -> Optional[dict]: + return _rooms.get(_room_key(guild_id, owner_id)) + + +def _set_room(guild_id: int, owner_id: int, data: dict): + _rooms[_room_key(guild_id, owner_id)] = data + mid = data.get("control_message_id") + if mid: + _control_message_ids[mid] = (guild_id, owner_id) + + +def _del_room(guild_id: int, owner_id: int): + data = _rooms.pop(_room_key(guild_id, owner_id), None) + if data and data.get("control_message_id"): + _control_message_ids.pop(data["control_message_id"], None) + + +def _find_room_by_channel(guild_id: int, channel_id: int) -> Optional[tuple[int, dict]]: + for (gid, oid), data in _rooms.items(): + if gid == guild_id and data.get("voice_channel_id") == channel_id: + return (oid, data) + return None + + +def _find_room_by_message(message_id: int) -> Optional[tuple[int, int, dict]]: + key = _control_message_ids.get(message_id) + if not key: + return None + guild_id, owner_id = key + data = _get_room(guild_id, owner_id) + if not data: + _control_message_ids.pop(message_id, None) + return None + return (guild_id, owner_id, data) + + +async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist: set, blacklist: set): + guild = channel.guild + everyone = guild.default_role + overwrites = {} + everyone_ow = discord.PermissionOverwrite() + if mode == "open": + everyone_ow.connect = True + everyone_ow.view_channel = True + for uid in blacklist: + m = guild.get_member(uid) + if m: + overwrites[m] = discord.PermissionOverwrite(connect=False, view_channel=True) + elif mode == "closed": + everyone_ow.connect = False + everyone_ow.view_channel = True + for uid in whitelist: + m = guild.get_member(uid) + if m: + overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True) + elif mode == "private": + everyone_ow.connect = False + everyone_ow.view_channel = False + for uid in whitelist: + m = guild.get_member(uid) + if m: + overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True) + overwrites[everyone] = everyone_ow + await channel.edit(overwrites=overwrites) + + +async def _handle_reaction_action(bot: discord.Client, guild_id: int, owner_id: int, action: str, channel): + """channel = salon vocal (partie texte / onglet Discussion).""" + room = _get_room(guild_id, owner_id) + if not room: + await channel.send("Ce salon n’existe plus.") + return + voice_channel = bot.get_channel(room["voice_channel_id"]) + if not voice_channel or not isinstance(voice_channel, discord.VoiceChannel): + await channel.send("Salon vocal introuvable.") + return + + if action in ("open", "closed", "private"): + room["access_mode"] = action + await _apply_access_mode(voice_channel, action, room.get("whitelist", set()), room.get("blacklist", set())) + # Mettre à jour le cadenas dans le nom du channel + try: + base_name = voice_channel.name.rstrip(" 🔓🔒") + new_name = f"{base_name} {_status_emoji(action)}" + await voice_channel.edit(name=new_name) + except discord.HTTPException: + pass + await channel.send(f"Accès du salon défini sur **{action}**.") + # Mettre à jour uniquement le statut (cadenas) dans le message de config + control_message_id = room.get("control_message_id") + if control_message_id: + try: + msg = await channel.fetch_message(control_message_id) + if msg.embeds: + embed = msg.embeds[0].copy() + for i, f in enumerate(embed.fields): + if f.name == "Statut du salon": + embed.set_field_at(i, name="Statut du salon", value=_status_display(action), inline=f.inline) + break + else: + embed.add_field(name="Statut du salon", value=_status_display(action), inline=False) + await msg.edit(embed=embed) + except discord.HTTPException: + pass + + elif action == "whitelist": + await channel.send("Liste blanche : mentionnez un membre pour l’ajouter/retirer.") + + elif action == "blacklist": + await channel.send("Liste noire : mentionnez un membre pour l’ajouter/retirer.") + + elif action == "purge": + whitelist = room.get("whitelist", set()) + kicked = 0 + for member in list(voice_channel.members): + if member.id == owner_id or member.id in whitelist: + continue + try: + await member.move_to(None) + kicked += 1 + except discord.HTTPException: + pass + await channel.send(f"Purge effectuée : {kicked} membre(s) déconnecté(s).") + + elif action == "transfer": + await channel.send("Transférer le salon : mentionnez le membre à qui donner la propriété.") + + elif action in ("speak", "stream"): + everyone = voice_channel.guild.default_role + overwrites = dict(voice_channel.overwrites) + ow = overwrites.get(everyone) or discord.PermissionOverwrite() + current = getattr(ow, action) + setattr(ow, action, not current if current is not None else False) + overwrites[everyone] = ow + await voice_channel.edit(overwrites=overwrites) + label = "Micro" if action == "speak" else "Vidéo" + await channel.send(f"{label} : {'autorisé' if getattr(ow, action) else 'désactivé'} pour tous.") + + elif action == "status": + status_text = _status_display(room.get("access_mode", "open")) + await channel.send(f"Statut du salon : {status_text}\nRépondez avec le nouveau nom du salon pour le modifier.") + + +async def send_control_panel(bot: discord.Client, guild_id: int, owner: Member, voice_channel: discord.VoiceChannel) -> Optional[int]: + """Envoie le message de config avec réactions dans la partie texte du salon vocal (onglet Discussion). Seul le proprio peut réagir. Retourne l’id du message.""" + embed = _build_control_embed(owner, voice_channel, "open") + + try: + # Message dans la partie texte du vocal (onglet Discussion à droite) + msg = await voice_channel.send(embed=embed) + for emoji, _action, _label in REACTIONS: + await msg.add_reaction(emoji) + return msg.id + except discord.HTTPException as e: + logging.error(f"Impossible d’envoyer le panneau Auto Room dans le vocal : {e}") + return None + + +async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member, before: VoiceState, after: VoiceState): + config = ConfigurationHelper() + if not config.getValue("auto_rooms_enable"): + return + trigger_channel_id = config.getIntValue("auto_rooms_channel_id") + if not trigger_channel_id: + return + + guild = member.guild + + if after.channel and after.channel.id == trigger_channel_id: + category = after.channel.category + # Nom du salon avec statut (cadenas) à la création + channel_name = f"Salon de {member.display_name} {_status_emoji('open')}" + try: + new_channel = await guild.create_voice_channel( + name=channel_name, + category=category, + reason="Auto room" + ) + await member.move_to(new_channel) + control_message_id = await send_control_panel(bot, guild.id, member, new_channel) + _set_room(guild.id, member.id, { + "guild_id": guild.id, + "voice_channel_id": new_channel.id, + "control_message_id": control_message_id, + "owner_id": member.id, + "whitelist": set(), + "blacklist": set(), + "access_mode": "open", + }) + logging.info(f"Auto room créé : {new_channel.name} pour {member.display_name}") + except discord.HTTPException as e: + logging.error(f"Erreur création auto room : {e}") + + if before.channel and before.channel.id != trigger_channel_id: + result = _find_room_by_channel(guild.id, before.channel.id) + if result: + owner_id, room = result + remaining = [m for m in before.channel.members if m.id != member.id] + if member.id == owner_id: + _del_room(guild.id, owner_id) + try: + await before.channel.delete(reason="Propriétaire parti (auto room)") + except discord.HTTPException: + pass + elif len(remaining) == 0: + _del_room(guild.id, owner_id) + try: + await before.channel.delete(reason="Auto room vide") + except discord.HTTPException: + pass + + +async def on_raw_reaction_add_auto_rooms(bot: discord.Client, payload: discord.RawReactionActionEvent): + """Seul le propriétaire peut réagir ; on retire la réaction des autres.""" + if payload.user_id == bot.user.id: + return + if not ConfigurationHelper().getValue("auto_rooms_enable"): + return + room_info = _find_room_by_message(payload.message_id) + if not room_info: + return + guild_id, owner_id, room = room_info + if payload.user_id != owner_id: + try: + channel = bot.get_channel(payload.channel_id) + if channel: + msg = await channel.fetch_message(payload.message_id) + user = payload.member or await bot.fetch_user(payload.user_id) + await msg.remove_reaction(payload.emoji, user) + except discord.HTTPException: + pass + return + + emoji_str = str(payload.emoji) + action = None + for e, a, _ in REACTIONS: + if e == emoji_str: + action = a + break + if not action: + return + + # Canal = salon vocal (le message est dans la partie texte du vocal) + channel = bot.get_channel(payload.channel_id) + if not channel or not hasattr(channel, "send"): + return + + await _handle_reaction_action(bot, guild_id, owner_id, action, channel) + + try: + msg = await channel.fetch_message(payload.message_id) + user = payload.member or await bot.fetch_user(payload.user_id) + await msg.remove_reaction(payload.emoji, user) + except discord.HTTPException: + pass diff --git a/discordbot/freeloot.py b/discordbot/freeloot.py new file mode 100644 index 0000000..c3598d5 --- /dev/null +++ b/discordbot/freeloot.py @@ -0,0 +1,232 @@ +# FreeLoot Discord : notifications depuis le feed LootScraper (jeux gratuits Epic, Amazon Prime, GOG, etc.) +import asyncio +import logging + +from discord import Client +from database import db +from database.helpers import ConfigurationHelper +from database.models import FreeLootEntry +from freeloot_feed import ( + SOURCES, + fetch_feed, + source_key_from_entry, + game_name_from_title, + extract_image_from_content, + extract_description_from_content, + extract_valid_to_from_content, + extract_recommended_price_from_content, + extract_genres_from_content, + extract_rating_from_content, +) + + +def _get_mention_content() -> str: + """Construit le contenu du message (mentions) depuis la config.""" + raw = ConfigurationHelper().getValue("freeloot_mention") + if not raw or not str(raw).strip(): + return "" + parts = [] + for s in str(raw).strip().split(","): + s = s.strip() + if s == "everyone": + parts.append("@everyone") + elif s == "here": + parts.append("@here") + elif s.isdigit(): + parts.append(f"<@&{s}>") + return " ".join(parts) if parts else "" + + +def _is_enabled_source(source_key: str) -> bool: + """Vérifie si cette source est activée dans la config (freeloot_sources).""" + raw = ConfigurationHelper().getValue("freeloot_sources") + if raw is None or (isinstance(raw, str) and raw.strip() == ""): + return True + enabled = [s.strip() for s in str(raw).split(",") if s.strip()] + return source_key in enabled if enabled else True + + +def _store_label_for_title(source_key: str) -> str: + """Libellé court pour le titre style DraftBot (ex: 'l'Epic Games Store').""" + labels = { + "epic_pc": "l'Epic Games Store", + "epic_android": "l'Epic Games Store (Android)", + "epic_ios": "l'Epic Games Store (iOS)", + "amazon_prime": "Amazon Prime Gaming", + "gog": "GOG", + "google_play": "Google Play", + "apple_app_store": "l'App Store", + } + return labels.get(source_key, "la boutique") + + +# Logo (thumbnail) de chaque boutique pour l'embed Discord (affiché en haut à droite) +SOURCE_LOGO_URLS = { + "epic_pc": "https://store.epicgames.com/favicon.ico", + "epic_android": "https://store.epicgames.com/favicon.ico", + "epic_ios": "https://store.epicgames.com/favicon.ico", + "amazon_prime": "https://gaming.amazon.com/favicon.ico", + "gog": "https://www.gog.com/favicon.ico", + "google_play": "https://play.google.com/favicon.ico", + "apple_app_store": "https://www.apple.com/favicon.ico", +} + + +def _build_embed(entry: dict, source_key: str): + import discord + game_name = game_name_from_title(entry["title"]) + source_label = next((s[1] for s in SOURCES if s[0] == source_key), source_key) + link = entry.get("link") or "" + content_raw = entry.get("content") or "" + img_url = extract_image_from_content(content_raw) + description = extract_description_from_content(content_raw, max_len=350) + valid_to = extract_valid_to_from_content(content_raw) + store_title = _store_label_for_title(source_key) + # Couleur barre gauche style DraftBot (orange-rouge) + color = 0xE67E22 + title = f"{game_name} gratuit sur {store_title} !" + embed = discord.Embed( + title=title, + url=link if link.startswith("http") else None, + color=color, + ) + if description: + embed.description = description + # Prix / gratuit / validité (Discord : pas de couleur dans le texte, seulement **gras** / markdown) + value_parts = ["**Gratuit**"] + if valid_to: + try: + from datetime import datetime + end = datetime.fromisoformat(valid_to.replace("Z", "+00:00")) + value_parts.append(f"jusqu'au {end.strftime('%d/%m/%Y')}") + except Exception: + value_parts.append(f"jusqu'au {valid_to[:10]}") + embed.add_field( + name="Prix", + value=" • ".join(value_parts), + inline=False, + ) + recommended_price = extract_recommended_price_from_content(content_raw) + if recommended_price: + embed.add_field(name="Prix recommandé", value=recommended_price, inline=True) + genres = extract_genres_from_content(content_raw) + if genres: + embed.add_field(name="Genres", value=genres, inline=True) + rating = extract_rating_from_content(content_raw) + if rating: + embed.add_field(name="Ratings", value=rating, inline=True) + if link and link.startswith("http"): + embed.add_field( + name="\u200b", + value=f"[Ouvrir dans la boutique !]({link})", + inline=False, + ) + # Thumbnail (logo de la boutique en haut à droite) + logo_url = SOURCE_LOGO_URLS.get(source_key) + if logo_url and logo_url.startswith("http"): + embed.set_thumbnail(url=logo_url) + # Image principale (style DraftBot) + if img_url and img_url.startswith("http"): + embed.set_image(url=img_url) + embed.set_footer(text="MamieHenriette • FreeLoot") + return embed + + +_freeloot_first_check = True + +async def checkFreeLootAndNotify(bot: Client): + global _freeloot_first_check + helper = ConfigurationHelper() + if not helper.getValue("freeloot_enable"): + return + channel_id = helper.getIntValue("freeloot_channel_id") + if not channel_id: + return + channel = bot.get_channel(channel_id) + if not channel: + logging.warning("FreeLoot: canal Discord introuvable") + return + entries = fetch_feed() + if not entries: + return + + # Au premier check après le démarrage, on synchronise sans notifier + if _freeloot_first_check: + logging.info("FreeLoot: première vérification, synchronisation sans notification") + for entry in entries: + entry_id = entry["id"] + if not FreeLootEntry.query.get(entry_id): + source_key = source_key_from_entry(entry["title"], entry["link"]) + if source_key and _is_enabled_source(source_key): + try: + db.session.add(FreeLootEntry(entry_id=entry_id)) + db.session.commit() + except Exception as e: + logging.error(f"FreeLoot: erreur de synchronisation pour {entry_id}: {e}") + db.session.rollback() + _freeloot_first_check = False + return + + # Vérifications suivantes : notification normale + for entry in entries: + entry_id = entry["id"] + if FreeLootEntry.query.get(entry_id): + continue + source_key = source_key_from_entry(entry["title"], entry["link"]) + if not source_key or not _is_enabled_source(source_key): + continue + try: + embed = _build_embed(entry, source_key) + content = _get_mention_content() + await channel.send(content=content or None, embed=embed) + db.session.add(FreeLootEntry(entry_id=entry_id)) + db.session.commit() + except Exception as e: + logging.error(f"FreeLoot: envoi Discord échoué pour {entry_id}: {e}") + db.session.rollback() + + +async def _send_entry_to_discord_async(bot: Client, entry_id: str) -> tuple[bool, str]: + """ + Envoie une entrée FreeLoot sur Discord (appel manuel). Retourne (succès, message). + """ + channel_id = ConfigurationHelper().getIntValue("freeloot_channel_id") + if not channel_id: + return (False, "Aucun canal Discord configuré pour FreeLoot.") + channel = bot.get_channel(channel_id) + if not channel: + return (False, "Canal Discord introuvable.") + entries = fetch_feed() + if not entries: + return (False, "Impossible de charger le flux.") + entry = next((e for e in entries if e.get("id") == entry_id), None) + if not entry: + return (False, "Entrée introuvable dans le flux.") + source_key = source_key_from_entry(entry["title"], entry["link"]) + if not source_key: + return (False, "Source non reconnue pour cette entrée.") + try: + embed = _build_embed(entry, source_key) + content = _get_mention_content() + await channel.send(content=content or None, embed=embed) + if not FreeLootEntry.query.get(entry_id): + db.session.add(FreeLootEntry(entry_id=entry_id)) + db.session.commit() + return (True, "Annonce envoyée sur Discord.") + except Exception as e: + logging.error(f"FreeLoot: envoi manuel échoué pour {entry_id}: {e}") + db.session.rollback() + return (False, str(e)) + + +def send_entry_to_discord_sync(bot: Client, entry_id: str) -> tuple[bool, str]: + """Appel synchrone pour envoyer une entrée sur Discord (depuis la webapp).""" + try: + future = asyncio.run_coroutine_threadsafe( + _send_entry_to_discord_async(bot, entry_id), + bot.loop, + ) + return future.result(timeout=15) + except Exception as e: + logging.error(f"FreeLoot: send_entry_to_discord_sync: {e}") + return (False, str(e)) diff --git a/discordbot/humblebundle.py b/discordbot/humblebundle.py index 7c5ebe5..07df3d0 100644 --- a/discordbot/humblebundle.py +++ b/discordbot/humblebundle.py @@ -8,6 +8,8 @@ from database.helpers import ConfigurationHelper from database.models import GameBundle from discord import Client +_humblebundle_first_check = True + def _isEnable(): helper = ConfigurationHelper() @@ -40,10 +42,22 @@ def _formatMessage(bundle): return message async def checkHumbleBundleAndNotify(bot: Client): + global _humblebundle_first_check if _isEnable() : try : bundles = _callGithub() bundle = _findFirstNotNotified(bundles) + + # Premier check : synchronisation sans notification + if _humblebundle_first_check: + if bundle != None: + logging.info(f'HumbleBundle: première vérification, synchronisation sans notification pour {bundle["name"]}') + db.session.add(GameBundle(url=bundle['url'], name=bundle['name'], json = json.dumps(bundle))) + db.session.commit() + _humblebundle_first_check = False + return + + # Vérifications normales ensuite if bundle != None : message = _formatMessage(bundle) await bot.get_channel(ConfigurationHelper().getIntValue('humble_bundle_channel')).send(message) diff --git a/discordbot/youtube.py b/discordbot/youtube.py index aa78962..8b85b7d 100644 --- a/discordbot/youtube.py +++ b/discordbot/youtube.py @@ -10,23 +10,31 @@ from webapp import webapp logger = logging.getLogger('youtube-notification') logger.setLevel(logging.INFO) +_youtube_first_check = True + async def checkYouTubeVideos(): + global _youtube_first_check with webapp.app_context(): try: notifications: list[YouTubeNotification] = YouTubeNotification.query.filter_by(enable=True).all() for notification in notifications: try: - await _checkChannelVideos(notification) + await _checkChannelVideos(notification, is_first_check=_youtube_first_check) except Exception as e: logger.error(f"Erreur lors de la vérification de la chaîne {notification.channel_id}: {e}") continue + + # Après la première vérification complète, on désactive le flag + if _youtube_first_check: + _youtube_first_check = False + logger.info("YouTube: première vérification terminée, notifications activées") except Exception as e: logger.error(f"Erreur lors de la vérification YouTube: {e}") -async def _checkChannelVideos(notification: YouTubeNotification): +async def _checkChannelVideos(notification: YouTubeNotification, is_first_check: bool = False): try: channel_id = notification.channel_id @@ -109,6 +117,15 @@ async def _checkChannelVideos(notification: YouTubeNotification): if videos: latest_video_id, latest_video = videos[0] + # Si c'est la première vérification après démarrage, on synchronise sans notifier + if is_first_check: + if not notification.last_video_id or notification.last_video_id != latest_video_id: + logger.info(f"YouTube: synchronisation initiale pour {channel_id}, dernière vidéo: {latest_video_id}") + notification.last_video_id = latest_video_id + db.session.commit() + return + + # Vérifications normales ensuite if not notification.last_video_id: notification.last_video_id = latest_video_id db.session.commit() diff --git a/freeloot_feed.py b/freeloot_feed.py new file mode 100644 index 0000000..721a913 --- /dev/null +++ b/freeloot_feed.py @@ -0,0 +1,200 @@ +# Module partagé : récupération et parsing du flux LootScraper (sans dépendance Discord) +import re +import xml.etree.ElementTree as ET +from html import unescape + +import requests + +FEED_URL = "https://feed.eikowagenknecht.com/lootscraper.xml" +ATOM_NS = {"atom": "http://www.w3.org/2005/Atom"} + +SOURCES = [ + ("epic_pc", "Epic Games (PC)", "🖥️"), + ("epic_android", "Epic Games (Android)", "🤖"), + ("epic_ios", "Epic Games (iOS)", "🍎"), + ("amazon_prime", "Amazon Prime Gaming", "📦"), + ("gog", "GOG", "🎮"), + ("google_play", "Google Play", "🤖"), + ("apple_app_store", "Apple App Store", "🍎"), +] + + +def source_key_from_entry(title: str, link: str) -> str | None: + """Détermine la clé source+plateforme depuis le titre et le lien.""" + title_upper = (title or "").upper() + link_lower = (link or "").lower() + if "EPIC GAMES" in title_upper: + if "-ios-" in link_lower or "/ios-" in link_lower: + return "epic_ios" + if "-android-" in link_lower or "/android-" in link_lower: + return "epic_android" + return "epic_pc" + if "AMAZON PRIME" in title_upper: + return "amazon_prime" + if "GOG" in title_upper: + return "gog" + if "GOOGLE PLAY" in title_upper: + return "google_play" + if "APPLE APP STORE" in title_upper: + return "apple_app_store" + return None + + +def game_name_from_title(title: str) -> str: + """Extrait le nom du jeu depuis le titre.""" + if not title: + return "Jeu gratuit" + for prefix in ( + "Epic Games (Game) - ", + "Amazon Prime (Game) - ", + "GOG (Game) - ", + "GOG (Game, Always Free) - ", + "Google Play (Game) - ", + "Apple App Store (Game) - ", + ): + if title.startswith(prefix): + return unescape(title[len(prefix) :].strip()) + if " - " in title: + return unescape(title.split(" - ", 1)[1].strip()) + return unescape(title.strip()) + + +def extract_image_from_content(content: str) -> str | None: + """Extrait l'URL de la première image du contenu HTML (toutes balises img).""" + if not content: + return None + + def _normalize_url(url: str) -> str: + u = unescape(url.strip()).replace("&", "&") + return u + + # 1) Balises + for m in re.finditer(r']+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 Description:).""" + if not content: + return None + m = re.search(r"Description:\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"Offer valid to:\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"Recommended price\s*\([^)]*\):\s*\s*([^<]+)", content, re.I) + if not m: + m = re.search(r"Recommended price[^<]*\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"Genres:\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"Ratings:\s*(.+?)", content, re.I | re.DOTALL) + if not m: + return None + raw = m.group(1) + raw = re.sub(r"]*>([^<]*)", r"\1", raw) + raw = re.sub(r"<[^>]+>", " ", raw) + raw = unescape(re.sub(r"\s+", " ", raw).strip()) + return raw if len(raw) > 0 and len(raw) < 200 else None + + +def fetch_feed() -> list[dict] | None: + """Récupère et parse le flux Atom, retourne une liste d'entrées brutes.""" + try: + r = requests.get(FEED_URL, timeout=15) + r.raise_for_status() + root = ET.fromstring(r.content) + entries = [] + for entry_el in root.findall(".//atom:entry", ATOM_NS): + entry_id_el = entry_el.find("atom:id", ATOM_NS) + title_el = entry_el.find("atom:title", ATOM_NS) + link_el = entry_el.find("atom:link", ATOM_NS) + content_el = entry_el.find("atom:content", ATOM_NS) + updated_el = entry_el.find("atom:updated", ATOM_NS) or entry_el.find("atom:published", ATOM_NS) + entry_id = entry_id_el.text.strip() if entry_id_el is not None and entry_id_el.text else None + title = title_el.text.strip() if title_el is not None and title_el.text else None + link = link_el.get("href") if link_el is not None else None + content = "" + if content_el is not None: + # Sérialiser tout le sous-arbre pour ne rien perdre (notamment les ) + content = ET.tostring(content_el, encoding="unicode", method="xml") + # Normaliser les préfixes de namespace (ex: -> ) pour que les regex d'extraction matchent + content = content.replace(" 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 diff --git a/requirements.txt b/requirements.txt index 0fe17e7..6584604 100755 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/twitchbot/__init__.py b/twitchbot/__init__.py index 9eb411c..0418957 100644 --- a/twitchbot/__init__.py +++ b/twitchbot/__init__.py @@ -6,27 +6,107 @@ from twitchAPI.type import AuthScope, ChatEvent from twitchAPI.chat import Chat, ChatEvent, ChatMessage, EventData from database.helpers import ConfigurationHelper +from database.models import Commande + + +def _user_has_twitch_permission(msg: ChatMessage, required: str) -> bool: + """Vérifie si l'utilisateur a le niveau de permission requis (viewer, sub, vip, moderator).""" + if not required or required == 'viewer': + return True + is_broadcaster = msg.user.name.lower() == msg.room.name.lower() + is_mod = msg.user.mod or is_broadcaster + if required == 'moderator': + return is_mod + if required == 'vip': + return msg.user.vip or is_mod + if required == 'sub': + return msg.user.subscriber or msg.user.vip or is_mod + return True from twitchbot.live_alert import checkOnlineStreamer from twitchbot.announcements import checkAndSendAnnouncements, incrementMessageCount +from twitchbot import moderation +from twitchbot import link_filter +from twitchbot import event_notifications from webapp import webapp -USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] +USER_SCOPE = [ + AuthScope.CHAT_READ, + AuthScope.CHAT_EDIT, + AuthScope.MODERATOR_MANAGE_BANNED_USERS, + AuthScope.MODERATOR_MANAGE_CHAT_MESSAGES, + AuthScope.MODERATOR_MANAGE_CHAT_SETTINGS, + AuthScope.MODERATOR_MANAGE_SHIELD_MODE, + AuthScope.CHANNEL_MANAGE_BROADCAST, + AuthScope.MODERATOR_READ_FOLLOWERS, + AuthScope.CHANNEL_READ_SUBSCRIPTIONS, # EventSub channel.subscribe (notifs sub) +] async def _onReady(ready_event: EventData): logging.info('Bot Twitch prêt') - channel = ConfigurationHelper().getValue('twitch_channel') - webapp.config["BOT_STATUS"]["twitch_connected"] = True - webapp.config["BOT_STATUS"]["twitch_channel_name"] = channel with webapp.app_context(): + channel = ConfigurationHelper().getValue('twitch_channel') + webapp.config["BOT_STATUS"]["twitch_connected"] = True + webapp.config["BOT_STATUS"]["twitch_channel_name"] = channel await ready_event.chat.join_room(channel) + # EventSub (follow, sub, raid) : besoin du broadcaster_id + try: + from twitchAPI.helper import first + user = await first(twitchBot.twitch.get_users(logins=[channel])) + if user: + twitchBot._eventsub = event_notifications.create_eventsub(twitchBot.twitch, asyncio.get_event_loop()) + asyncio.create_task(event_notifications.register_eventsub_handlers(twitchBot._eventsub, user.id, ready_event.chat, channel)) + except Exception as e: + logging.warning('EventSub non démarré: %s', e) asyncio.get_event_loop().create_task(twitchBot._checkOnlineStreamers()) asyncio.get_event_loop().create_task(twitchBot._runAnnouncements()) + asyncio.get_event_loop().create_task(twitchBot._checkClips()) async def _onMessage(msg: ChatMessage): logging.info(f'Dans {msg.room.name}, {msg.user.name} a dit : {msg.text}') incrementMessageCount() + + # Stocker le message dans BOT_STATUS pour l'affichage web + with webapp.app_context(): + from datetime import datetime + message_data = { + 'username': msg.user.name, + 'text': msg.text, + 'timestamp': datetime.now().isoformat(), + 'is_mod': msg.user.mod, + 'is_subscriber': msg.user.subscriber, + 'is_vip': msg.user.vip, + 'color': getattr(msg.user, 'color', None) or '#9146FF' + } + messages = webapp.config["BOT_STATUS"]["twitch_chat_messages"] + messages.append(message_data) + # Limiter à 100 messages + if len(messages) > 100: + messages.pop(0) + + if not await link_filter.check_message_for_links(msg, twitchBot.twitch): + return + if not await moderation.check_message_for_banned_words(msg, twitchBot.twitch): + return + await _handleCustomCommand(msg) + + +async def _handleCustomCommand(msg: ChatMessage): + if not msg.text.startswith('!'): + return + trigger = msg.text.split()[0].lower() + with webapp.app_context(): + # Vérifier si les commandes Twitch sont activées globalement + if not ConfigurationHelper().getValue('twitch_commands_enable'): + return + commande = Commande.query.filter_by(trigger=trigger, twitch_enable=True).first() + if commande: + permission = commande.twitch_permission or 'viewer' + if not _user_has_twitch_permission(msg, permission): + return # Pas de réponse = l'utilisateur n'a pas la permission + response = commande.response.replace('{user}', msg.user.name) + await msg.reply(response) async def _helloCommand(msg: ChatMessage): @@ -43,6 +123,7 @@ def _isConfigured(): class TwitchBot(): + _eventsub = None async def _connect(self): with webapp.app_context(): @@ -55,12 +136,55 @@ class TwitchBot(): self.chat.register_event(ChatEvent.READY, _onReady) self.chat.register_event(ChatEvent.MESSAGE, _onMessage) self.chat.register_command('hello', _helloCommand) + self._register_moderation_commands() self.chat.start() except Exception as e: logging.error(f'Échec de l\'authentification Twitch : {e}') else: logging.info("Twitch n'est pas configuré") + def _register_moderation_commands(self): + # Créer des wrappers async pour chaque commande + async def cmd_timeout(msg): await moderation.timeout_command(msg, self.twitch) + async def cmd_ban(msg): await moderation.ban_command(msg, self.twitch) + async def cmd_unban(msg): await moderation.unban_command(msg, self.twitch) + async def cmd_clean(msg): await moderation.clean_command(msg, self.twitch) + async def cmd_shieldmode(msg): await moderation.shieldmode_command(msg, self.twitch) + async def cmd_settitle(msg): await moderation.settitle_command(msg, self.twitch) + async def cmd_setgame(msg): await moderation.setgame_command(msg, self.twitch) + async def cmd_subon(msg): await moderation.subon_command(msg, self.twitch) + async def cmd_suboff(msg): await moderation.suboff_command(msg, self.twitch) + async def cmd_follon(msg): await moderation.follon_command(msg, self.twitch) + async def cmd_folloff(msg): await moderation.folloff_command(msg, self.twitch) + async def cmd_emoteon(msg): await moderation.emoteon_command(msg, self.twitch) + async def cmd_emoteoff(msg): await moderation.emoteoff_command(msg, self.twitch) + async def cmd_ann(msg): await moderation.ann_command(msg, self.twitch) + async def cmd_no_game(msg): await moderation.no_game_command(msg, self.twitch) + async def cmd_multitwitch(msg): await moderation.multitwitch_command(msg, self.twitch) + async def cmd_permit(msg): await link_filter.permit_command(msg, self.twitch) + + self.chat.register_command('kick', cmd_timeout) + self.chat.register_command('to', cmd_timeout) + self.chat.register_command('timeout', cmd_timeout) + self.chat.register_command('tm', cmd_timeout) + self.chat.register_command('ban', cmd_ban) + self.chat.register_command('unban', cmd_unban) + self.chat.register_command('clean', cmd_clean) + self.chat.register_command('shieldmode', cmd_shieldmode) + self.chat.register_command('settitle', cmd_settitle) + self.chat.register_command('setgame', cmd_setgame) + self.chat.register_command('setcateg', cmd_setgame) + self.chat.register_command('subon', cmd_subon) + self.chat.register_command('suboff', cmd_suboff) + self.chat.register_command('follon', cmd_follon) + self.chat.register_command('folloff', cmd_folloff) + self.chat.register_command('emoteon', cmd_emoteon) + self.chat.register_command('emoteoff', cmd_emoteoff) + self.chat.register_command('ann', cmd_ann) + self.chat.register_command('no_game', cmd_no_game) + self.chat.register_command('multitwitch', cmd_multitwitch) + self.chat.register_command('permit', cmd_permit) + async def _checkOnlineStreamers(self): while True: try: @@ -70,13 +194,61 @@ class TwitchBot(): await asyncio.sleep(5 * 60) async def _runAnnouncements(self): - channel = ConfigurationHelper().getValue('twitch_channel') + with webapp.app_context(): + channel = ConfigurationHelper().getValue('twitch_channel') while True: try: - await checkAndSendAnnouncements(self.chat, channel) + await checkAndSendAnnouncements(self.chat, channel, self.twitch) except Exception as e: logging.error(f'Erreur envoi annonces : {e}') - await asyncio.sleep(60) + await asyncio.sleep(2 * 60) # Vérification toutes les 2 min pour limiter le spam + + async def _checkClips(self): + """Vérifie le clip le plus récent (polling) et notifie si nouveau.""" + with webapp.app_context(): + channel = ConfigurationHelper().getValue('twitch_channel') + if not channel: + return + from twitchAPI.helper import first + try: + user = await first(self.twitch.get_users(logins=[channel])) + if not user: + return + except Exception: + return + while True: + try: + with webapp.app_context(): + from database.models import TwitchEventNotification + cfg = TwitchEventNotification.query.filter_by(event_type='clip', enable=True).first() + if not cfg: + await asyncio.sleep(120) + continue + clip = await first(self.twitch.get_clips(broadcaster_id=user.id, first=1)) + if not clip: + await asyncio.sleep(120) + continue + if cfg.last_clip_id is None: + with webapp.app_context(): + from database import db + cfg = TwitchEventNotification.query.filter_by(event_type='clip', enable=True).first() + if cfg: + cfg.last_clip_id = clip.id + db.session.commit() + elif clip.id != cfg.last_clip_id: + with webapp.app_context(): + await event_notifications.notify_clip( + self.chat, + channel, + user=getattr(clip, 'creator_name', None) or getattr(clip, 'user_name', '') or clip.id, + title=getattr(clip, 'title', '') or 'Clip', + url=getattr(clip, 'url', '') or f'https://clips.twitch.tv/{clip.id}', + thumbnail_url=getattr(clip, 'thumbnail_url', '') or '', + clip_id=clip.id, + ) + except Exception as e: + logging.error('Erreur check clips: %s', e) + await asyncio.sleep(120) def begin(self): asyncio.run(self._connect()) diff --git a/twitchbot/announcements.py b/twitchbot/announcements.py index d6c0bcf..6b9f4fc 100644 --- a/twitchbot/announcements.py +++ b/twitchbot/announcements.py @@ -2,6 +2,7 @@ import logging from datetime import datetime, timedelta from twitchAPI.chat import Chat +from twitchAPI.twitch import Twitch from database import db from database.models import TwitchAnnouncement @@ -10,25 +11,110 @@ from webapp import webapp logger = logging.getLogger('twitch-announcements') logger.setLevel(logging.INFO) +# Délai minimum entre deux annonces (quelle qu'elles soient) pour éviter le spam +MIN_DELAY_BETWEEN_ANNOUNCEMENTS_MINUTES = 5 -async def checkAndSendAnnouncements(chat: Chat, channel: str): +_message_count: int = 0 +_last_announcement_index: int = -1 # Pour la rotation round-robin + + +def incrementMessageCount(): + global _message_count + _message_count += 1 + + +def _getAndResetMessageCount() -> int: + global _message_count + count = _message_count + _message_count = 0 + return count + + +async def _is_channel_live(twitch: Twitch, channel: str) -> bool: + """Vérifie si la chaîne Twitch est actuellement en live.""" + try: + async for _ in twitch.get_streams(user_login=[channel]): + return True + except Exception as e: + logger.warning(f'Impossible de vérifier le statut live de {channel}: {e}') + return False + + +async def checkAndSendAnnouncements(chat: Chat, channel: str, twitch: Twitch): """ - Vérifie et envoie les annonces dont la périodicité est écoulée. + Envoie une annonce en rotation parmi celles dont la périodicité est écoulée, + uniquement si la chaîne est en live et qu'il y a assez d'activité dans le chat. Appelé périodiquement par le bot Twitch. """ + global _last_announcement_index + with webapp.app_context(): - announcements: list[TwitchAnnouncement] = TwitchAnnouncement.query.filter_by(enable=True).all() + if not await _is_channel_live(twitch, channel): + return + + announcements: list[TwitchAnnouncement] = TwitchAnnouncement.query.filter_by(enable=True).order_by(TwitchAnnouncement.id).all() + if not announcements: + return + now = datetime.now() - - for announcement in announcements: - if _shouldSend(announcement, now): - try: - await _sendAnnouncement(chat, channel, announcement) - announcement.last_sent = now - db.session.commit() - logger.info(f'Annonce envoyée : {announcement.name}') - except Exception as e: - logger.error(f'Erreur lors de l\'envoi de l\'annonce "{announcement.name}": {e}') + + # Ne pas envoyer si une annonce (quelle qu'elle soit) a été envoyée récemment + last_any = max((a.last_sent for a in announcements if a.last_sent), default=None) + if last_any and (now - last_any) < timedelta(minutes=MIN_DELAY_BETWEEN_ANNOUNCEMENTS_MINUTES): + return + + # Filtrer les annonces dont la périodicité est écoulée + due = [a for a in announcements if _shouldSend(a, now)] + if not due: + return + + # Rotation round-robin : chercher la prochaine annonce après la dernière envoyée + announcement = _selectNextAnnouncement(due, announcements) + if not announcement: + return + + # Vérifier le nombre minimum de messages dans le chat + message_count = _getAndResetMessageCount() + if message_count < announcement.min_chat_messages: + logger.debug(f'Annonce "{announcement.name}" ignorée : seulement {message_count} messages (minimum requis : {announcement.min_chat_messages})') + return + + try: + await _sendAnnouncement(chat, channel, announcement) + announcement.last_sent = now + _last_announcement_index = announcements.index(announcement) + db.session.commit() + logger.info(f'Annonce envoyée : {announcement.name} (après {message_count} messages)') + except Exception as e: + logger.error(f'Erreur lors de l\'envoi de l\'annonce "{announcement.name}": {e}') + + +def _selectNextAnnouncement(due: list[TwitchAnnouncement], all_announcements: list[TwitchAnnouncement]) -> TwitchAnnouncement | None: + """ + Sélectionne la prochaine annonce selon un système de rotation round-robin. + Cherche la première annonce éligible après la dernière envoyée. + """ + global _last_announcement_index + + if not due: + return None + + # Si c'est la première annonce ou si l'index est invalide, prendre la première de la liste + if _last_announcement_index == -1 or _last_announcement_index >= len(all_announcements): + return due[0] + + # Chercher la prochaine annonce éligible après la dernière envoyée + start_index = _last_announcement_index + 1 + + # Parcourir depuis l'index suivant jusqu'à la fin, puis revenir au début + for i in range(len(all_announcements)): + idx = (start_index + i) % len(all_announcements) + announcement = all_announcements[idx] + if announcement in due: + return announcement + + # Fallback : retourner la première annonce éligible + return due[0] def _shouldSend(announcement: TwitchAnnouncement, now: datetime) -> bool: diff --git a/twitchbot/event_notifications.py b/twitchbot/event_notifications.py new file mode 100644 index 0000000..bafe748 --- /dev/null +++ b/twitchbot/event_notifications.py @@ -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") diff --git a/twitchbot/link_filter.py b/twitchbot/link_filter.py new file mode 100644 index 0000000..775beb1 --- /dev/null +++ b/twitchbot/link_filter.py @@ -0,0 +1,166 @@ +import re +import logging +from datetime import datetime, timedelta + +from twitchAPI.twitch import Twitch +from twitchAPI.chat import ChatMessage + +from database import db +from database.models import TwitchLinkFilter, TwitchAllowedDomain, TwitchPermit, TwitchAllowedUser +from twitchbot.moderation import _log_action, _get_broadcaster_id, _get_moderator_id, _get_user_id, _is_moderator +from webapp import webapp + +logger = logging.getLogger('twitch-link-filter') +logger.setLevel(logging.INFO) + +URL_REGEX = re.compile(r'https?://[^\s]+|(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,}(?:/[^\s]*)?') + + +def _get_filter_config(): + """Retourne un dictionnaire avec la config du filtre de liens""" + with webapp.app_context(): + config = TwitchLinkFilter.query.first() + if not config: + config = TwitchLinkFilter(enabled=False) + db.session.add(config) + db.session.commit() + # Retourner un dict pour éviter DetachedInstanceError + return { + 'enabled': config.enabled, + 'allow_subscribers': config.allow_subscribers, + 'allow_vips': config.allow_vips, + 'allow_moderators': config.allow_moderators, + 'timeout_duration': config.timeout_duration, + 'warning_message': config.warning_message + } + + +def _get_allowed_domains(): + with webapp.app_context(): + return [d.domain.lower() for d in TwitchAllowedDomain.query.all()] + + +def _is_user_whitelisted(username: str) -> bool: + with webapp.app_context(): + return TwitchAllowedUser.query.filter_by(username=username.lower()).first() is not None + + +def _has_valid_permit(username: str) -> bool: + with webapp.app_context(): + permit = TwitchPermit.query.filter_by(username=username.lower()).first() + if permit and permit.expires_at > datetime.now(): + return True + if permit and permit.expires_at <= datetime.now(): + db.session.delete(permit) + db.session.commit() + return False + + +def _extract_domain(url: str) -> str: + url = url.lower() + url = re.sub(r'^https?://', '', url) + url = re.sub(r'^www\.', '', url) + return url.split('/')[0] + + +def _is_domain_allowed(url: str, allowed_domains: list) -> bool: + domain = _extract_domain(url) + for allowed in allowed_domains: + if domain == allowed or domain.endswith('.' + allowed): + return True + return False + + +async def check_message_for_links(msg: ChatMessage, twitch: Twitch) -> bool: + config = _get_filter_config() + + if not config['enabled']: + return True + + if config['allow_moderators'] and (msg.user.mod or msg.user.name.lower() == msg.room.name.lower()): + return True + + if config['allow_vips'] and msg.user.vip: + return True + + if config['allow_subscribers'] and msg.user.subscriber: + return True + + if _is_user_whitelisted(msg.user.name): + return True + + urls = URL_REGEX.findall(msg.text) + if not urls: + return True + + if _has_valid_permit(msg.user.name): + with webapp.app_context(): + permit = TwitchPermit.query.filter_by(username=msg.user.name.lower()).first() + if permit: + db.session.delete(permit) + db.session.commit() + return True + + allowed_domains = _get_allowed_domains() + for url in urls: + if not _is_domain_allowed(url, allowed_domains): + await _handle_unauthorized_link(msg, twitch, config, url) + return False + + return True + + +async def _handle_unauthorized_link(msg: ChatMessage, twitch: Twitch, config: dict, url: str): + broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name) + moderator_id = await _get_moderator_id(twitch) + user_id = await _get_user_id(twitch, msg.user.name) + + if user_id and config['timeout_duration'] > 0: + try: + await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason="Lien non autorise", duration=config['timeout_duration']) + except Exception as e: + logger.error(f"Erreur timeout link filter: {e}") + + try: + await twitch.delete_chat_messages(broadcaster_id, moderator_id, message_id=msg.id) + except Exception as e: + logger.error(f"Erreur suppression message: {e}") + + if config['warning_message']: + await msg.reply(config['warning_message']) + + _log_action("link_blocked", "AutoMod", msg.user.name, _extract_domain(url)) + logger.info(f"Lien bloque de {msg.user.name}: {url}") + + +async def permit_command(msg: ChatMessage, twitch: Twitch): + if not _is_moderator(msg): + return + + args = msg.text.split()[1:] + if len(args) < 1: + await msg.reply("Usage: !permit [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}") diff --git a/twitchbot/live_alert.py b/twitchbot/live_alert.py index 726a130..6029884 100644 --- a/twitchbot/live_alert.py +++ b/twitchbot/live_alert.py @@ -1,4 +1,5 @@ import logging +import discord from twitchAPI.twitch import Twitch from twitchAPI.object.api import Stream @@ -11,11 +12,76 @@ from webapp import webapp logger = logging.getLogger('live-alert') logger.setLevel(logging.INFO) +_live_alert_first_check = True + + +def _stream_thumbnail_url(stream: Stream) -> str: + """URL de la miniature du stream (preview Twitch).""" + url = getattr(stream, 'thumbnail_url', None) or '' + if '{width}' in url or '{height}' in url: + url = url.replace('{width}', '320').replace('{height}', '180') + if not url: + url = f"https://static-cdn.jtvnw.net/previews-ttv/live_user_{stream.user_login}-320x180.jpg" + return url + + +def _format_embed_text(text: str, stream: Stream, stream_url: str, thumbnail: str) -> str: + """Formate un texte d'embed avec les variables stream.""" + if not text: + return '' + try: + return text.format( + user_login=stream.user_login or '', + user_name=stream.user_name or '', + game_name=getattr(stream, 'game_name', None) or '', + title=stream.title or '', + language=getattr(stream, 'language', None) or '', + stream_url=stream_url, + thumbnail=thumbnail, + ) + except KeyError: + return text + async def checkOnlineStreamer(twitch: Twitch) : + global _live_alert_first_check with webapp.app_context() : alerts : list[LiveAlert] = LiveAlert.query.all() streams = await _retreiveStreams(twitch, alerts) + watch_stream = None + + # Récupération du statut du live principal (channel configuré) + from database.helpers import ConfigurationHelper + main_channel = ConfigurationHelper().getValue('twitch_channel') + main_stream = None + if main_channel: + main_stream = next((s for s in streams if s.user_login.lower() == main_channel.lower()), None) + + # Mise à jour du BOT_STATUS pour la webapp + if main_stream: + webapp.config["BOT_STATUS"]["twitch_is_live"] = True + webapp.config["BOT_STATUS"]["twitch_viewer_count"] = getattr(main_stream, 'viewer_count', 0) + else: + webapp.config["BOT_STATUS"]["twitch_is_live"] = False + webapp.config["BOT_STATUS"]["twitch_viewer_count"] = 0 + + # Premier check : synchronisation sans notification + if _live_alert_first_check: + logger.info('Live Alert: première vérification, synchronisation sans notification') + for alert in alerts: + stream = next((s for s in streams if s.user_login == alert.login), None) + if stream: + alert.online = True + if alert.watch_activity and alert.enable: + watch_stream = stream + else: + alert.online = False + await _updateBotActivity(watch_stream) + db.session.commit() + _live_alert_first_check = False + return + + # Vérifications normales ensuite for alert in alerts : stream = next((s for s in streams if s.user_login == alert.login), None) if stream : @@ -24,26 +90,103 @@ async def checkOnlineStreamer(twitch: Twitch) : logger.info(f'N\'etait pas en ligne auparavant : {alert.login}') await _notifyAlert(alert, stream) alert.online = True + if alert.watch_activity and alert.enable: + watch_stream = stream else : logger.info(f'Streamer hors ligne : {alert.login}') alert.online = False + + await _updateBotActivity(watch_stream) db.session.commit() -async def _notifyAlert(alert : LiveAlert, stream : Stream): - message : str = alert.message.format(stream) - logger.info(f'Message de notification : {message}') - bot.loop.create_task(_sendMessage(alert.notify_channel, message)) -async def _sendMessage(channel : int, message : str) : - logger.info(f'Envoi de notification : {message}') - await bot.get_channel(channel).send(message) - logger.info(f'Notification envoyé') +async def _updateBotActivity(stream: Stream | None): + if stream: + logger.info(f'Mise à jour de l\'activité : Regarde le live de {stream.user_name}') + activity = discord.Streaming( + name=f'Regarde le live de {stream.user_name}', + url=f'https://www.twitch.tv/{stream.user_login}' + ) + await bot.change_presence(status=discord.Status.online, activity=activity) + else: + logger.info('Aucun stream à regarder, retour à l\'activité normale') + # Remettre une humeur aléatoire + from database.models import Humeur + import random + humeurs = Humeur.query.all() + if humeurs: + humeur = random.choice(humeurs) + logger.info(f'Réinitialisation du statut : {humeur.text}') + await bot.change_presence(status=discord.Status.online, activity=discord.CustomActivity(humeur.text)) + else: + # Si pas de humeur, remettre un statut par défaut + await bot.change_presence(status=discord.Status.online, activity=None) -async def _retreiveStreams(twitch: Twitch, alerts : list[LiveAlert]) -> list[Stream] : - streams : list[Stream] = [] +async def _notifyAlert(alert: LiveAlert, stream: Stream): + stream_url = f'https://www.twitch.tv/{stream.user_login}' + thumbnail = _stream_thumbnail_url(stream) + + # Message texte optionnel (avant l'embed) + message_text = None + if alert.message and alert.message.strip(): + try: + message_text = alert.message.format(stream) + except KeyError: + message_text = alert.message + + # Construction de l'embed Discord + try: + embed_color = int(alert.embed_color or '9146FF', 16) + except ValueError: + embed_color = 0x9146FF + + embed_title = _format_embed_text(alert.embed_title, stream, stream_url, thumbnail) if alert.embed_title else (stream.title or f"{stream.user_name} est en live") + embed_description = _format_embed_text(alert.embed_description, stream, stream_url, thumbnail) if alert.embed_description else None + + embed = discord.Embed( + title=embed_title, + url=stream_url, + color=embed_color + ) + if embed_description: + embed.description = embed_description + + author_name = _format_embed_text(alert.embed_author_name, stream, stream_url, thumbnail) if alert.embed_author_name else stream.user_name + user_id = getattr(stream, 'user_id', None) + author_icon = alert.embed_author_icon or (f"https://static-cdn.jtvnw.net/jtv_user_pictures/{user_id}-profile_image-70x70.png" if user_id else "https://static-cdn.jtvnw.net/ttv-favicon/favicon-32x32.png") + embed.set_author(name=author_name, icon_url=author_icon) + + if alert.embed_thumbnail and thumbnail: + embed.set_thumbnail(url=thumbnail) + if alert.embed_image and thumbnail: + embed.set_image(url=thumbnail) + + if alert.embed_footer: + footer_text = _format_embed_text(alert.embed_footer, stream, stream_url, thumbnail) + if footer_text: + embed.set_footer(text=footer_text) + + logger.info(f'Envoi de notification live (embed) : {stream.user_login}') + bot.loop.create_task(_sendMessage(alert.notify_channel, message_text, embed)) + +async def _sendMessage(channel_id: int, message: str | None, embed: discord.Embed): + try: + discord_channel = bot.get_channel(channel_id) + if not discord_channel: + logger.error(f"Canal Discord {channel_id} introuvable") + return + if message and message.strip(): + await discord_channel.send(content=message, embed=embed) + else: + await discord_channel.send(embed=embed) + logger.info('Notification live envoyée') + except Exception as e: + logger.error(f"Erreur lors de l'envoi de la notification live : {e}") + +async def _retreiveStreams(twitch: Twitch, alerts: list[LiveAlert]) -> list[Stream]: + streams: list[Stream] = [] logger.info(f'Recherche de streams pour : {alerts}') - async for stream in twitch.get_streams(user_login = [alert.login for alert in alerts]): + async for stream in twitch.get_streams(user_login=[alert.login for alert in alerts]): streams.append(stream) logger.info(f'Ces streams sont en ligne : {streams}') return streams - diff --git a/twitchbot/moderation.py b/twitchbot/moderation.py new file mode 100644 index 0000000..e7c05e6 --- /dev/null +++ b/twitchbot/moderation.py @@ -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 [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 [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 [viewer2] ...") + return + + broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name) + moderator_id = await _get_moderator_id(twitch) + + for viewer in args[:5]: + viewer = viewer.lstrip('@') + user_id = await _get_user_id(twitch, viewer) + if user_id: + await twitch.unban_user(broadcaster_id, moderator_id, user_id) + _log_action("unban", msg.user.name, viewer) + logger.info(f'{viewer} débanni par {msg.user.name}') + + +async def clean_command(msg: ChatMessage, twitch: Twitch): + if not _is_moderator(msg): + return + + args = msg.text.split()[1:] + broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name) + moderator_id = await _get_moderator_id(twitch) + + if len(args) >= 1: + viewer = args[0].lstrip('@') + user_id = await _get_user_id(twitch, viewer) + if user_id: + await twitch.delete_chat_messages(broadcaster_id, moderator_id, user_id=user_id) + _log_action("clean", msg.user.name, viewer) + logger.info(f'Messages de {viewer} supprimés par {msg.user.name}') + else: + await twitch.delete_chat_messages(broadcaster_id, moderator_id) + _log_action("clean", msg.user.name, None, "Chat complet") + logger.info(f'Chat nettoyé par {msg.user.name}') + + +async def shieldmode_command(msg: ChatMessage, twitch: Twitch): + if not _is_moderator(msg): + return + + args = msg.text.split()[1:] + if len(args) < 1: + await msg.reply("Usage: !shieldmode ") + 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 ") + 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 ") + 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 ") + 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 ") + return + + action = args[0].lower() + + if action == "on": + games_disabled = True + logger.info(f'Jeux désactivés par {msg.user.name}') + await msg.reply("Jeux désactivés") + elif action == "off": + games_disabled = False + logger.info(f'Jeux activés par {msg.user.name}') + await msg.reply("Jeux activés") + + +def are_games_disabled() -> bool: + return games_disabled + + +async def check_message_for_banned_words(msg: ChatMessage, twitch: Twitch) -> bool: + """ + Vérifie si le message contient des mots interdits. + Retourne True si le message est valide, False s'il doit être bloqué. + """ + # Modérateurs et broadcaster exemptés + if msg.user.mod or msg.user.name.lower() == msg.room.name.lower(): + return True + + with webapp.app_context(): + banned_words = TwitchBannedWord.query.filter_by(enabled=True).all() + if not banned_words: + return True + + message_lower = msg.text.lower() + + for banned_word_entry in banned_words: + word = banned_word_entry.word.lower() + # Recherche du mot dans le message (mot entier ou partie de mot) + if word in message_lower: + # Bloquer le message + broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name) + moderator_id = await _get_moderator_id(twitch) + user_id = await _get_user_id(twitch, msg.user.name) + + # Timeout de l'utilisateur + if user_id and banned_word_entry.timeout_duration > 0: + try: + await twitch.ban_user( + broadcaster_id, + moderator_id, + user_id, + reason=f"Mot interdit: {banned_word_entry.word}", + duration=banned_word_entry.timeout_duration + ) + except Exception as e: + logger.error(f"Erreur timeout mot interdit: {e}") + + # Suppression du message + try: + await twitch.delete_chat_messages(broadcaster_id, moderator_id, message_id=msg.id) + except Exception as e: + logger.error(f"Erreur suppression message mot interdit: {e}") + + # Log + _log_action("banned_word", "AutoMod", msg.user.name, f"Mot: {banned_word_entry.word}") + logger.info(f"Mot interdit détecté de {msg.user.name}: {banned_word_entry.word}") + + return False + + return True diff --git a/webapp/__init__.py b/webapp/__init__.py index b06c327..ef2d6fe 100644 --- a/webapp/__init__.py +++ b/webapp/__init__.py @@ -1,13 +1,57 @@ +import os from flask import Flask +from flask_login import LoginManager webapp = Flask(__name__) +# Secret key pour les sessions (Flask-Login) +webapp.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-production") + # État des bots (mis à jour par les bots, lu par le panneau) webapp.config["BOT_STATUS"] = { "discord_connected": False, "discord_guild_count": 0, "twitch_connected": False, "twitch_channel_name": None, + "twitch_is_live": False, + "twitch_viewer_count": 0, + "twitch_chat_messages": [], # Derniers messages du chat (max 100) } -from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements +login_manager = LoginManager() +login_manager.init_app(webapp) +login_manager.login_view = "login" +login_manager.login_message = "Veuillez vous connecter pour accéder à cette page." + +from database.models import WebappUser + +@login_manager.user_loader +def load_user(user_id): + try: + return WebappUser.query.get(int(user_id)) + except (ValueError, TypeError): + return None + +from webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot + +from flask import request, redirect, url_for +from flask_login import current_user + +@webapp.context_processor +def inject_user_level(): + from flask_login import current_user + from database.helpers import ConfigurationHelper + reg = ConfigurationHelper().getValue("registration_enabled") + registration_enabled = reg not in (None, "", "false", "0", "no", "off") + return { + "current_user_level": current_user.get_level() if current_user.is_authenticated else -1, + "registration_enabled": registration_enabled, + } + +@webapp.before_request +def require_login(): + """Redirige vers /login si non authentifié (sauf login, register, static, callback Twitch OAuth).""" + if request.endpoint in (None, "login", "register", "static", "twitchReceiveToken"): + return + if not current_user.is_authenticated: + return redirect(url_for("login", next=request.url)) diff --git a/webapp/announcements.py b/webapp/announcements.py index 32d97f0..f65308a 100644 --- a/webapp/announcements.py +++ b/webapp/announcements.py @@ -1,18 +1,23 @@ from flask import render_template, request, redirect, url_for from webapp import webapp +from webapp.auth import require_page, can_write_page from database import db from database.models import TwitchAnnouncement @webapp.route("/announcements") +@require_page("announcements") def openAnnouncements(): announcements = TwitchAnnouncement.query.all() return render_template("announcements.html", announcements=announcements) @webapp.route("/announcements/add", methods=['POST']) +@require_page("announcements") def addAnnouncement(): + if not can_write_page("announcements"): + return render_template("403.html"), 403 announcement = TwitchAnnouncement( enable=True, name=request.form.get('name'), @@ -26,7 +31,10 @@ def addAnnouncement(): @webapp.route("/announcements/toggle/") +@require_page("announcements") def toggleAnnouncement(id): + if not can_write_page("announcements"): + return render_template("403.html"), 403 announcement = TwitchAnnouncement.query.get_or_404(id) announcement.enable = not announcement.enable db.session.commit() @@ -34,13 +42,17 @@ def toggleAnnouncement(id): @webapp.route("/announcements/edit/") +@require_page("announcements") def openEditAnnouncement(id): announcement = TwitchAnnouncement.query.get_or_404(id) return render_template("announcements.html", announcement=announcement) @webapp.route("/announcements/edit/", methods=['POST']) +@require_page("announcements") def submitEditAnnouncement(id): + if not can_write_page("announcements"): + return render_template("403.html"), 403 announcement = TwitchAnnouncement.query.get_or_404(id) announcement.name = request.form.get('name') announcement.text = request.form.get('text') @@ -51,7 +63,10 @@ def submitEditAnnouncement(id): @webapp.route("/announcements/del/") +@require_page("announcements") def delAnnouncement(id): + if not can_write_page("announcements"): + return render_template("403.html"), 403 announcement = TwitchAnnouncement.query.get_or_404(id) db.session.delete(announcement) db.session.commit() @@ -59,7 +74,10 @@ def delAnnouncement(id): @webapp.route("/announcements/reset/") +@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() diff --git a/webapp/auth.py b/webapp/auth.py new file mode 100644 index 0000000..5b61ad1 --- /dev/null +++ b/webapp/auth.py @@ -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")) diff --git a/webapp/commandes.py b/webapp/commandes.py index 29bbd75..6954454 100644 --- a/webapp/commandes.py +++ b/webapp/commandes.py @@ -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/") +@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/") +@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/") +@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() diff --git a/webapp/configurations.py b/webapp/configurations.py index a247570..05585d8 100644 --- a/webapp/configurations.py +++ b/webapp/configurations.py @@ -1,14 +1,17 @@ from flask import render_template, request, redirect, url_for from webapp import webapp +from webapp.auth import require_page from database import db from database.helpers import ConfigurationHelper from discordbot import bot @webapp.route("/configurations") +@require_page("configurations") def openConfigurations(): - return render_template("configurations.html", configuration = ConfigurationHelper(), channels = bot.getAllTextChannel(), roles = bot.getAllRoles()) + return render_template("configurations.html", configuration=ConfigurationHelper(), channels=bot.getAllTextChannel(), voice_channels=bot.getAllVoiceChannels(), roles=bot.getAllRoles()) -@webapp.route("/configurations/update", methods=['POST']) +@webapp.route("/configurations/update", methods=['POST']) +@require_page("configurations") def updateConfiguration(): checkboxes = { 'humble_bundle_enable': 'humble_bundle_channel', @@ -17,7 +20,9 @@ def updateConfiguration(): 'moderation_ban_enable': 'moderation_staff_role_ids', 'moderation_kick_enable': 'moderation_staff_role_ids', 'welcome_enable': 'welcome_channel_id', - 'leave_enable': 'leave_channel_id' + 'leave_enable': 'leave_channel_id', + 'auto_rooms_enable': 'auto_rooms_channel_id', + 'twitch_commands_enable': 'twitch_channel' } staff_roles = request.form.getlist('moderation_staff_role_ids') diff --git a/webapp/freeloot.py b/webapp/freeloot.py new file mode 100644 index 0000000..e911f08 --- /dev/null +++ b/webapp/freeloot.py @@ -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})) diff --git a/webapp/humeurs.py b/webapp/humeurs.py index 8fef853..92fddc6 100644 --- a/webapp/humeurs.py +++ b/webapp/humeurs.py @@ -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/') +@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')) diff --git a/webapp/index.py b/webapp/index.py index ffe42b2..3fd94f1 100644 --- a/webapp/index.py +++ b/webapp/index.py @@ -1,11 +1,15 @@ from flask import render_template from webapp import webapp -from database.models import ModerationEvent +from webapp.auth import require_page +from database.models import ModerationEvent, TwitchAnnouncement, TwitchModerationLog @webapp.route("/") +@require_page("index") def index(): status = webapp.config["BOT_STATUS"] sanctions_count = ModerationEvent.query.count() + twitch_announcements_count = TwitchAnnouncement.query.count() + twitch_moderation_count = TwitchModerationLog.query.count() return render_template( "index.html", discord_connected=status["discord_connected"], @@ -13,4 +17,6 @@ def index(): sanctions_count=sanctions_count, twitch_connected=status["twitch_connected"], twitch_channel_name=status["twitch_channel_name"], + twitch_announcements_count=twitch_announcements_count, + twitch_moderation_count=twitch_moderation_count, ) diff --git a/webapp/link_filter.py b/webapp/link_filter.py new file mode 100644 index 0000000..cff074d --- /dev/null +++ b/webapp/link_filter.py @@ -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/") +@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/") +@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')) diff --git a/webapp/live_alert.py b/webapp/live_alert.py index bd365ff..3571649 100644 --- a/webapp/live_alert.py +++ b/webapp/live_alert.py @@ -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/") +@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/") +@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/", 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/") +@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/") +@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() diff --git a/webapp/moderation.py b/webapp/moderation.py index e0d92f4..621cb57 100644 --- a/webapp/moderation.py +++ b/webapp/moderation.py @@ -1,5 +1,6 @@ from flask import render_template, request, redirect, url_for from webapp import webapp +from webapp.auth import require_page, can_write_page from database import db from database.models import ModerationEvent @@ -30,6 +31,7 @@ def _top_moderators(): ) @webapp.route("/moderation") +@require_page("moderation") def moderation(): events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all() top_sanctioned = _top_sanctioned() @@ -43,6 +45,7 @@ def moderation(): ) @webapp.route("/moderation/edit/") +@require_page("moderation") def open_edit_moderation_event(event_id): event = ModerationEvent.query.get_or_404(event_id) events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all() @@ -57,14 +60,20 @@ def open_edit_moderation_event(event_id): ) @webapp.route("/moderation/update/", 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/") +@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() diff --git a/webapp/protondb.py b/webapp/protondb.py index 32eee87..8ab71af 100644 --- a/webapp/protondb.py +++ b/webapp/protondb.py @@ -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/') -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')) diff --git a/webapp/settings.py b/webapp/settings.py new file mode 100644 index 0000000..52a4f72 --- /dev/null +++ b/webapp/settings.py @@ -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//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//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")) diff --git a/webapp/templates/403.html b/webapp/templates/403.html new file mode 100644 index 0000000..d0dbf18 --- /dev/null +++ b/webapp/templates/403.html @@ -0,0 +1,9 @@ +{% extends "template.html" %} + +{% block content %} +
+

Accès refusé

+

Vous n'avez pas les droits nécessaires pour accéder à cette page.

+ Retour à l'accueil +
+{% endblock %} diff --git a/webapp/templates/commandes.html b/webapp/templates/commandes.html index 77568d8..d24f44e 100644 --- a/webapp/templates/commandes.html +++ b/webapp/templates/commandes.html @@ -1,55 +1,130 @@ {% extends "template.html" %} {% block content %} -

Commandes de Mamie

-

Gérez les commandes personnalisées du bot. Ces commandes peuvent être activées sur Discord et/ou Twitch selon vos besoins.

- - - - - - - - - - - - {% for commande in commandes %} - - - - - - - - {% endfor %} - -
CommandeRéponseDiscordTwitchActions
{{ commande.trigger }}{{ commande.response }} - - {{ '✅' if commande.discord_enable else '❌' }} - - - - {{ '✅' if commande.twitch_enable else '❌' }} - - - Supprimer -
+
+

Commandes de Mamie

+
+

+ Gérez les commandes personnalisées du bot. Ces commandes peuvent être activées sur Discord et/ou Twitch selon vos besoins. +

+
+
-

Ajouter une commande

-
- - - - -
- - +
+

Liste des commandes

+
+
+ + + + + + + + + + + + + {% for commande in commandes %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
CommandeRéponseDiscordTwitchPermission TwitchActions
+ {{ commande.trigger }} + {{ commande.response }} + + {{ '✅' if commande.discord_enable else '❌' }} + + + + {{ '✅' if commande.twitch_enable else '❌' }} + + + {% if commande.twitch_enable %} + + {{ twitch_permissions.get(commande.twitch_permission or 'viewer', 'Tous') }} + + {% else %} + + {% endif %} + + + + +
+ Aucune commande configurée. Ajoutez-en une ci-dessous. +
+
-
- - -
- - -{% endblock %} \ No newline at end of file +
+ +
+

Ajouter une commande

+ +
+
+
+ + +
+ +
+ + +
+ + +
+
+
+ +
+ + +
+ +
+ +
+
+
+{% endblock %} diff --git a/webapp/templates/configurations.html b/webapp/templates/configurations.html index 40cf0bf..11610fa 100644 --- a/webapp/templates/configurations.html +++ b/webapp/templates/configurations.html @@ -1,240 +1,341 @@ {% extends "template.html" %} {% block content %} -

Configuration de Mamie

-

Configurez les tokens Discord, les notifications Humble Bundle et l'API Twitch.

+{% with messages = get_flashed_messages(with_categories=true) %} +{% if messages %} +
+ {% for category, msg in messages %} +
+ {{ msg }} +
+ {% endfor %} +
+{% endif %} +{% endwith %} +
+

Configuration de Mamie

+
+

+ Configurez les tokens Discord, les notifications Humble Bundle et l'API Twitch. +

+
+
-

Discord

-
-
- API Discord - - - Nécessite un redémarrage après modification -
- -
- Messages de bienvenue - - - - - - - - - Syntaxes disponibles :
- • {member.mention} - Mentionne l'utilisateur (@NomUtilisateur)
- • {member.name} - Nom d'utilisateur (sans mention)
- • {member.display_name} - Surnom sur le serveur
- • {member.id} - ID de l'utilisateur
- • {server.name} - Nom du serveur
- • {server.member_count} - Nombre total de membres
- • <#ID_DU_CHANNEL> - Mentionne un salon (ex: <#123456789012345678>) -
-
- -
- Messages de départ - - - - - - - - - Syntaxes disponibles :
- • {member.mention} - Mentionne l'utilisateur (@NomUtilisateur)
- • {member.name} - Nom d'utilisateur (sans mention)
- • {member.display_name} - Surnom sur le serveur
- • {member.id} - ID de l'utilisateur
- • {server.name} - Nom du serveur
- • {server.member_count} - Nombre total de membres
- • <#ID_DU_CHANNEL> - Mentionne un salon (ex: <#123456789012345678>) -
-
- -
- Modération - - - - - - - - - Toutes les actions de modération seront notifiées dans ce canal - - - {% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %} - - {% if roles|length > 1 %} -
- {% for guild_data in roles %} - - {% endfor %} +
+
+
+ +

Discord

- {% endif %} - {% for guild_data in roles %} -
-
- {% for role in guild_data.roles %} - - {% endfor %} + +
+

API Discord

+
+ + +

Nécessite un redémarrage après modification

+
+ +
+

Messages de bienvenue

+ + + +
+ + +
+ +
+ + +
+

{member.mention} Mentionne l'utilisateur

+

{member.name} Nom d'utilisateur

+

{server.name} Nom du serveur

+

{server.member_count} Nombre de membres

+
+
+
+ +
+

Messages de départ

+ + + +
+ + +
+ +
+ + +
+
+ +
+

Auto Rooms (salons vocaux temporaires)

+

+ 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 partie texte du vocal (onglet Discussion à droite quand on ouvre le salon). Seul le propriétaire peut réagir. +

+ +
+ + +

Ex. « + Créer votre salon » — les membres qui rejoignent ce canal obtiennent un salon vocal dédié.

+
+
+ +
+

Modération

+ +
+ + + + + +
+ +
+ + +

Toutes les actions de modération seront notifiées dans ce canal

+
+ +
+ + {% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %} + + {% if roles|length > 1 %} +
+ {% for guild_data in roles %} + + {% endfor %} +
+ {% endif %} + + {% for guild_data in roles %} +
+
+ {% for role in guild_data.roles %} + + {% endfor %} +
+
+ {% endfor %} +

Sélectionnez les rôles qui peuvent utiliser les commandes de modération

+
+ +
+ + +

Mettre 0 pour ne pas supprimer automatiquement

+
+
+ + + +
+ +
+
+ +

API Twitch

- {% endfor %} - Sélectionnez un ou plusieurs rôles qui peuvent utiliser les commandes de modération +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+

Fonctionnalités du bot Twitch

+ + +

Les commandes configurées dans la page "Commandes" seront actives dans le chat Twitch

+
+ +
+ + Aide +
+ + {% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %} +
+ + + Obtenir token et refresh token + + +
+
+ + +
+
+ + +
+
+

Nécessite un redémarrage après l'obtention des Tokens.

+
+ {% endif %} +
+
+ +
+
+ +

Humble Bundle

+
- - - - - - - Mettre 0 pour ne pas supprimer automatiquement -
+
+
+

+ 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. +

+ + +
+ +
+ + +
- -
+ + +
+ -

API Twitch

-
- - - - - - - -

- Aide -

- {% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %} -

- Obtenir token et refresh token -

- - - - -

Nécessite un redémarrage après l'obtention des Tokens.

- {% endif %} -
- -

Humble Bundle

-
-

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.

- - - - - - - -
-{% endblock %} \ No newline at end of file + +{% endblock %} diff --git a/webapp/templates/freeloot.html b/webapp/templates/freeloot.html new file mode 100644 index 0000000..3a287b0 --- /dev/null +++ b/webapp/templates/freeloot.html @@ -0,0 +1,224 @@ +{% extends "template.html" %} + +{% block content %} +
+

FreeLoot — Jeux gratuits

+ {% if request.args.get('msg') %} + {% set msg_type = request.args.get('type') %} +
+ {{ request.args.get('msg') }} +
+ {% endif %} +
+

+ Notifications des jeux gratuits (Epic Games, Amazon Prime, GOG, Google Play, Apple App Store) via le flux + LootScraper. + 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. +

+
+
+ +
+
+ 🎁 +

Configuration FreeLoot

+
+ +
+
+ +
+ +
+ + +
+ +
+

Mentions (optionnel)

+

Choisissez qui mentionner au début du message (avant l’embed).

+
+ + +
+ {% if roles %} +
+

Rôles à mentionner

+ {% if roles|length > 1 %} +
+ {% for guild_data in roles %} + + {% endfor %} +
+ {% endif %} + {% for guild_data in roles %} +
+ {% for role in guild_data.roles %} + + {% endfor %} +
+ {% endfor %} +
+ {% endif %} +
+ +
+

Types de loot à notifier

+

Cochez les sources et plateformes pour lesquelles vous voulez recevoir une notification.

+
+ {% for key, label, emoji in sources %} + + {% endfor %} +
+
+ + +
+ +
+

Aperçu de l’embed Discord (style DraftBot)

+

Exemple du message envoyé dans le canal.

+
+
+ +

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…

+
+ Prix +

Gratuit • jusqu'au 05/02/2026

+
+
+
Prix recommandé

39.99 EUR

+
Genres

Simulation, Indie

+
Ratings

PEGI 18, USK 18

+
+

Ouvrir dans la boutique !

+
+ 🎁 +
+

MamieHenriette • FreeLoot

+
+
+
+
+ + + +{% if entries %} +
+
+
+

Jeux gratuits actuellement disponibles

+
+
+
+ {% for e in entries %} +
+
+ {% if e.image_url %} + + {% else %} + 🎁 + {% endif %} +
+
+

{{ e.game_name }}

+

+ {{ e.emoji }} + {{ e.source_label }} +

+ {% if e.recommended_price or e.genres or e.rating %} +
+ {% if e.recommended_price %}

Prix recommandé: {{ e.recommended_price }}

{% endif %} + {% if e.genres %}

Genres: {{ e.genres }}

{% endif %} + {% if e.rating %}

Ratings: {{ e.rating }}

{% endif %} +
+ {% endif %} + {% if e.updated_formatted %} +

{{ e.updated_formatted }}

+ {% endif %} +
+
+ + +
+ {% if e.link %} + + Ouvrir dans la boutique + + + {% endif %} +
+
+
+ {% endfor %} +
+
+
+
+{% else %} +
+

Le flux LootScraper n’a pas pu être chargé. Réessayez plus tard.

+
+{% endif %} +{% endblock %} diff --git a/webapp/templates/humeurs.html b/webapp/templates/humeurs.html index a060ccf..5d6a47b 100644 --- a/webapp/templates/humeurs.html +++ b/webapp/templates/humeurs.html @@ -1,29 +1,69 @@ {% extends "template.html" %} {% block content %} -

Humeurs de Mamie

-

Définissez les statuts Discord qui changeront automatiquement toutes les 10 minutes pour donner de la personnalité à votre bot.

- - - - - - - - - {% for humeur in humeurs %} - - - - - {% endfor %} - -
TexteAction
{{humeur.text}}Supprimer
+
+

Humeurs de Mamie

+
+

+ Définissez les statuts Discord qui changeront automatiquement toutes les 10 minutes pour donner de la personnalité à votre bot. +

+
+
-

Ajouter une humeur

-
- - - -
-{% endblock %} \ No newline at end of file +
+

Liste des humeurs

+
+
+ + + + + + + + + {% for humeur in humeurs %} + + + + + {% else %} + + + + {% endfor %} + +
TexteAction
{{ humeur.text }} + + + +
+ Aucune humeur configurée. Ajoutez-en une ci-dessous. +
+
+
+
+ +
+

Ajouter une humeur

+ +
+
+ + +
+ +
+ +
+
+
+{% endblock %} diff --git a/webapp/templates/index.html b/webapp/templates/index.html index bb109a5..56ddebe 100644 --- a/webapp/templates/index.html +++ b/webapp/templates/index.html @@ -10,7 +10,6 @@

-{# Zone Discord #}
@@ -43,7 +42,6 @@
-{# Zone Twitch #}
@@ -60,12 +58,19 @@

{% if twitch_channel_name %}{{ twitch_channel_name }}{% else %}—{% endif %}

-

Sanctions

-

-

À venir

+

Annonces configurées

+

{{ twitch_announcements_count }}

-
-

Intégrations du bot Twitch à venir.

+
+

Actions de modération

+

{{ twitch_moderation_count }}

+
+
diff --git a/webapp/templates/link-filter.html b/webapp/templates/link-filter.html new file mode 100644 index 0000000..b657aca --- /dev/null +++ b/webapp/templates/link-filter.html @@ -0,0 +1,176 @@ +{% extends "template.html" %} + +{% block content %} +
+
+

Filtre de liens

+

Bloquez les liens non autorises sur votre chat Twitch.

+
+ +
+
+
+
+ + + +
+
+

Protection des liens

+

{{ 'Active' if config.enabled else 'Desactive' }}

+
+
+ + {{ 'Desactiver' if config.enabled else 'Activer' }} + +
+ +
+
+
+

Autoriser les liens pour

+ + + +
+ +
+
+ + +

0 = pas de timeout, juste suppression du message

+
+
+ + +
+
+
+ + +
+
+ +
+
+
+

+ + + + Domaines autorises ({{ domains|length }}) +

+
+ +
+
+ + +
+
+ + {% if domains %} +
+
+ {% for domain in domains %} +
+ {{ domain.domain }} + Supprimer +
+ {% endfor %} +
+
+ {% else %} +
+ Aucun domaine autorise +
+ {% endif %} +
+ +
+
+

+ + + + Viewers autorises ({{ users|length }}) +

+
+ +
+
+ + +
+
+ + {% if users %} +
+
+ {% for user in users %} +
+ @{{ user.username }} + Supprimer +
+ {% endfor %} +
+
+ {% else %} +
+ Aucun viewer en liste blanche +
+ {% endif %} +
+
+ +
+

+ + + + Commande Permit +

+

+ Utilisez la commande !permit pour autoriser temporairement un viewer a poster un lien. +

+
+
+ !permit @viewer + Autorise 1 min +
+
+ !permit @viewer 5 + Autorise 5 min +
+
+
+
+{% endblock %} diff --git a/webapp/templates/live-alert.html b/webapp/templates/live-alert.html index f7ed4a3..c474cf9 100644 --- a/webapp/templates/live-alert.html +++ b/webapp/templates/live-alert.html @@ -1,76 +1,335 @@ {% extends "template.html" %} {% block content %} -

Alerte Live

- -

- Liste des chaines surveillées pour les alertes de live twitch. - - Le bot vérifie toutes les 5 minutes qui est en live dans la liste en dessous. - Le bot enregistre le status de stream toutes les 5 minutes, quand le status pass de "hors-ligne" à "en ligne" alors - le bot le notifiera sur discord. - Ne peu surveiller qu'au maximum 100 chaines. -

+
+

Alerte Live

+
+

+ Liste des chaînes surveillées pour les alertes de live Twitch. + Le bot vérifie toutes les 5 minutes qui est en live dans la liste en dessous. + Le bot enregistre le status de stream toutes les 5 minutes, quand le status passe de "hors-ligne" à "en ligne" alors + le bot enverra une notification (embed Discord) sur le canal choisi. + Ne peut surveiller qu'au maximum 100 chaînes. +

+
+
{% if not alert %} -

Alertes

- - - - - - - - - - - {% for alert in alerts %} - - - - - - - {% endfor %} - -
ChaineCanalMessage#
{{alert.login}}{{alert.notify_channel_name}}{{alert.message}} - {{ '✅' if alert.enable else '❌' }} - - 🗑 -
+
+

Alertes configurées

+
+
+ + + + + + + + + + + + {% for alert in alerts %} + + + + + + + + {% else %} + + + + {% endfor %} + +
ChaîneCanalMessage / EmbedActivitéActions
+ {{alert.login}} + {{alert.notify_channel_name}}{{alert.message or '(embed)'}} + + {{ '👁️' if alert.watch_activity else '👁️‍🗨️' }} + + + +
+ Aucune alerte configurée. Ajoutez-en une ci-dessous. +
+
+
+
{% endif %} -

{{ 'Editer une alerte' if alert else 'Ajouter une alerte de Live' }}

-
- - - - - - - -

- La chaine est le login de la chaine, par exemple chainesteve pour https://www.twitch.tv/chainesteve. -

-

- Pour le message vous avez acces à ces variables : -

    -
  • {0.user_login} : pour le lien vers la chaine
  • -
  • {0.user_name} : à priviligier pour le text
  • -
  • {0.game_name}
  • -
  • {0.title}
  • -
  • {0.language}
  • -
- Le message est au format common-mark dans la limite de ce que - support discord. - Pour mettre un lien vers la chaine : [description](https://www.twitch.tv/{0.user_login}) -

-
+
+

+ {{ 'Modifier l\'alerte' if alert else 'Ajouter une alerte de Live' }} +

-{% endblock %} \ No newline at end of file +
+
+
+
+

Configuration de base

+ +
+ + +
+ +
+ + +
+ +
+ + +

Variables: {user_name}, {title}, [lien](https://www.twitch.tv/{user_login})

+
+ +
+ + +
+
+ +
+

Personnalisation de l'embed Discord

+ +
+ + +

Variables: {title}, {user_name}, {game_name}, {stream_url}, {user_login}

+
+ +
+ + +
+ +
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + {% if alert %} + + Annuler + + {% endif %} +
+
+
+ +
+

Prévisualisation de l'embed Discord

+
+
+ + Nom du streamer +
+ Titre du stream +
+
+ +
+
+ +
+ +
+

Cette prévisualisation est approximative.

+ +
+

Variables disponibles (embed)

+
    +
  • {user_login} — Login Twitch
  • +
  • {user_name} — Nom d'affichage
  • +
  • {game_name} — Jeu en cours
  • +
  • {title} — Titre du stream
  • +
  • {language} — Langue
  • +
  • {stream_url} — Lien Twitch
  • +
  • {thumbnail} — URL preview
  • +
+
+
+
+
+ + +{% endblock %} diff --git a/webapp/templates/login.html b/webapp/templates/login.html new file mode 100644 index 0000000..f973a62 --- /dev/null +++ b/webapp/templates/login.html @@ -0,0 +1,35 @@ +{% extends "template.html" %} + +{% block content %} +
+

Connexion

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, msg in messages %} +

{{ msg }}

+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+
+ + +
+
+ + +
+ +
+ + {% if registration_enabled %} +

+ Pas encore de compte ? Créer un compte +

+ {% endif %} +
+{% endblock %} diff --git a/webapp/templates/protondb.html b/webapp/templates/protondb.html index 6cf256c..4f32a34 100644 --- a/webapp/templates/protondb.html +++ b/webapp/templates/protondb.html @@ -1,61 +1,135 @@ {% extends "template.html" %} {% block content %} -

Proton DB

-

ProtonDB évalue la compatibilité des jeux Windows sur Linux via Steam Play.

+
+

ProtonDB

+
+

+ ProtonDB évalue la compatibilité des jeux Windows sur Linux via Steam Play. +

+
+
{% if configuration.getValue('proton_db_enable_enable') %} -

Game alias

- - - - - - - - - - {% for a in aliases %} - - - - - - {% endfor %} - -
AliasGame#
{{a.alias}}{{a.name}}Supprimer
+
+

Alias de jeux

+
+
+ + + + + + + + + + {% for a in aliases %} + + + + + + {% else %} + + + + {% endfor %} + +
AliasJeuAction
+ {{ a.alias }} + {{ a.name }} + + + +
+ Aucun alias configuré. Ajoutez-en un ci-dessous. +
+
+
+
-

Ajouter un Alias

-
- - - - - -

Si vous créez un alias GTA : Grand Theft Auto alors si un utilisateur rentre la commande - !protondb GTA 5 cela fera une recherche sur Grand Theft Auto 5. -

-
+
+

Ajouter un alias

+ +
+
+
+ + +
+
+ + +
+
+ +
+

+ Si vous créez un alias GTAGrand Theft Auto, + alors la commande !protondb GTA 5 fera une recherche sur Grand Theft Auto 5. +

+
+ +
+ +
+
+
{% endif %} -

Configuration

-
- - - - - - - - -

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), - comme le montre cet exemple -

-
- - -{% endblock %} \ No newline at end of file +
+

Configuration

+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+

+ 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). + Voir l'exemple +

+
+ +
+ +
+
+
+{% endblock %} diff --git a/webapp/templates/register.html b/webapp/templates/register.html new file mode 100644 index 0000000..c54445a --- /dev/null +++ b/webapp/templates/register.html @@ -0,0 +1,41 @@ +{% extends "template.html" %} + +{% block content %} +
+

Créer un compte

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, msg in messages %} +

{{ msg }}

+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +

+ Déjà un compte ? Se connecter +

+
+{% endblock %} diff --git a/webapp/templates/settings.html b/webapp/templates/settings.html new file mode 100644 index 0000000..f4b943c --- /dev/null +++ b/webapp/templates/settings.html @@ -0,0 +1,386 @@ +{% extends "template.html" %} + +{% block content %} +
+
+
+

Paramètres et Permissions

+

Configuration des rôles et des accès aux pages (super administrateur uniquement)

+
+ +
+
+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, msg in messages %} +
+ {{ msg }} +
+ {% endfor %} +
+ {% endif %} +{% endwith %} + +
+ +
+
+
+ + + +
+
+

Inscriptions

+

Autoriser ou bloquer la création de nouveaux comptes par les visiteurs

+
+ +
+
+
+
+ + +
+
+
+
+ + + +
+
+

Hiérarchie des rôles

+

+ Les rôles définissent le niveau d'accès des utilisateurs. Plus le niveau est élevé, plus les permissions sont étendues. +

+
+
+
+ +
+
+ {% for r in roles %} +
+
+
+ +
+
+
+

{{ r.name }}

+ + Niveau {{ r.level }} + +
+ {% if r.description %} +

{{ r.description }}

+ {% else %} + {% set default_desc = default_roles_meta.get(r.name, {}).get('description') %} + {% if default_desc %} +

{{ default_desc }}

+ {% endif %} + {% endif %} + +
+ + + + + Modifier ce rôle + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {% if r.name not in ['viewer_twitch','utilisateur_discord','moderateur_discord','expert_discord','moderateur_twitch','super_administrateur'] %} + + {% endif %} +
+
+
+
+
+
+ {% endfor %} +
+ +
+ + + + + Créer un nouveau rôle + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+
+
+ + + +
+
+

Accès aux pages

+

+ Définissez le rôle minimum requis pour accéder à chaque section de l'interface +

+
+
+
+ + +
+
+ + + + + ⚡ Modification en masse + +
+
+ + aux pages cochées : +
+
+ + +
+
+ {% for page_key, meta in page_metadata.items() %} + + {% endfor %} +
+ +
+
+
+ + +
+ {% for category_key in ['general', 'content', 'moderation', 'config', 'admin'] %} + {% if category_key in pages_by_category %} + {% set category_info = category_labels[category_key] %} +
+
+
+ +
+

{{ category_info.label }}

+
+
+ +
+ {% 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 %} + +
+
+
+

{{ meta.label }}

+

{{ meta.description }}

+
+ {% for r in roles %} + {% if r.level == min_lvl %} + + {{ r.name }} + + {% endif %} + {% endfor %} +
+
+ + + +
+
+ {% endfor %} +
+
+ {% endif %} + {% endfor %} +
+
+
+ + + + + +{% endblock %} diff --git a/webapp/templates/template.html b/webapp/templates/template.html index d33d0ec..e171b20 100644 --- a/webapp/templates/template.html +++ b/webapp/templates/template.html @@ -94,6 +94,10 @@ Notification Twitch + + + Événements Twitch (sub, raid, clip) + Notification YouTube @@ -102,6 +106,10 @@ ProtonDB + + 🎁 + FreeLoot +
@@ -111,6 +119,10 @@ Modération + + + Auto Rooms +
@@ -128,10 +140,22 @@ Alerte live + + + Événements (sub, raid, clip) + Annonces + + + Moderation + + + + Filtre de liens + @@ -141,10 +165,29 @@ Configuration + {% if current_user.is_authenticated and current_user_level >= 5 %} + + + Utilisateurs + + + + Paramètres + + {% endif %}
+ {% if current_user.is_authenticated %} + + Déconnexion + {% else %} + Connexion + {% if registration_enabled %} + Créer un compte + {% endif %} + {% endif %}
diff --git a/webapp/templates/twitch-events.html b/webapp/templates/twitch-events.html new file mode 100644 index 0000000..fcd333c --- /dev/null +++ b/webapp/templates/twitch-events.html @@ -0,0 +1,117 @@ +{% extends "template.html" %} + +{% block content %} +
+

Notifications d'événements Twitch

+
+

+ Configurez les notifications pour les abonnements, follows, raids et nouveaux clips. + Pour chaque type d'événement vous pouvez activer l'envoi dans le chat Twitch et/ou dans un canal Discord. + Les événements sub, follow et raid utilisent Twitch EventSub ; les clips sont détectés par vérification périodique. +

+
+
+ +
+ {% for cfg in configs %} +
+
+

{{ labels[cfg.event_type] }}

+ +
+
+
+
+

Où notifier

+ + +
+ + +
+
+
+
+ + +

+ Sub/Follow: {user} {user_name} — + Raid: {from_broadcaster_name} {viewers} — + Clip: {user} {title} {url} +

+
+
+ + +
+
+
+
+

Embed Discord (optionnel)

+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+
+ {% endfor %} + +
+ +
+
+{% endblock %} diff --git a/webapp/templates/twitch-moderation.html b/webapp/templates/twitch-moderation.html new file mode 100644 index 0000000..18a968f --- /dev/null +++ b/webapp/templates/twitch-moderation.html @@ -0,0 +1,964 @@ +{% extends "template.html" %} + +{% block content %} + + +
+
+

Modération Twitch

+

Commandes et logs de modération pour {{ twitch_channel }}

+
+ + +
+ +
+
+
+
+ + + +
+
+

Live

+

{{ 'En ligne' if is_live else 'Hors ligne' }}

+
+
+ {% if is_live %} +
+
{{ viewer_count }}
+
viewers
+
+ {% endif %} +
+
+ + +
+
+
+ +
+

Filtre de liens

+

{{ 'Actif' if link_filter_enabled else 'Inactif' }}

+
+
+ Configurer +
+
+ + +
+
+
+
+ + + +
+
+

Mots interdits

+

{{ banned_words|length }} mot{{ 's' if banned_words|length > 1 else '' }}

+
+
+
+
+
+ + +
+ +
+
+ Commandes de modération + +
+
+ + + + + + + + + + {% for cmd in commands %} + + + + + + {% endfor %} + +
Commande(s)UsagePermission
+ {% for c in cmd.commands %} + {{ c }} + {% if not loop.last %} {% endif %} + {% endfor %} + {{ cmd.usage }}{{ cmd.permission }}
+
+
+ + +
+
+ Logs de modération ({{ logs|length }}) +
+ + {% if logs %} + Effacer + {% endif %} +
+
+
+ {% if logs %} + + + + + + + + + + + + {% for log in logs %} + + + + + + + + {% endfor %} + +
DateActionModoCibleDétails
{{ log.created_at.strftime('%d/%m %H:%M') }}{{ log.action }}{{ log.moderator }}{{ log.target or '-' }}{{ log.details or '-' }}
+ {% else %} +
Aucun log
+ {% endif %} +
+
+
+ + +
+ {% if is_live %} + +
+ +
+
+ +
+
+
+ + + + + EN DIRECT + • {{ viewer_count }} viewers +
+ + + + + Ouvrir sur Twitch + +
+
+ + +
+

+ + + + Nouvelle commande +

+
+ + + + +
+
+
+ + +
+ {% else %} + +
+ +
+

+ + + + Ajouter une commande +

+
+
+ + +
+ + +
+
+ + +
+ {% endif %} +
+
+

Chat en direct

+ + + + +
+ + + + + Ouvrir sur Twitch + +
+ + +
+
+
+
+ + + +
+

Récupération du chat...

+

Les messages du chat Twitch apparaîtront ici en temps réel

+
+
+
+ + +
+ +
+ + + + + + + + + + + +
+ + +
+ + +
+

Envoyé via le bot • Ouvrir le chat

+
+
+
+
+ + {% if custom_commands %} +
+
+

Commandes personnalisées Twitch ({{ custom_commands|length }})

+
+
+ + + + + + + + + + + {% for cmd in custom_commands %} + + + + + + + {% endfor %} + +
CommandeRéponsePermissionActions
{{ cmd.trigger }}{{ cmd.response }}{{ twitch_permissions.get(cmd.twitch_permission or 'viewer', 'Tous') }}Supprimer
+
+
+ {% endif %} + + +
+
+

Mots interdits ({{ banned_words|length }})

+
+ + +
+
+
+ + +
+
+ + +
+ +
+
+ + + {% if banned_words %} +
+ + + + + + + + + + + {% for word in banned_words %} + + + + + + + {% endfor %} + +
MotTimeoutAjouté leActions
{{ word.word }}{{ word.timeout_duration }}s{{ word.created_at.strftime('%d/%m/%Y %H:%M') if word.created_at else '-' }}Supprimer
+
+ {% else %} +
Aucun mot interdit configuré
+ {% endif %} +
+
+ + +{% endblock %} diff --git a/webapp/templates/users.html b/webapp/templates/users.html new file mode 100644 index 0000000..038e608 --- /dev/null +++ b/webapp/templates/users.html @@ -0,0 +1,292 @@ +{% extends "template.html" %} + +{% block content %} +
+
+
+
+ + + +
+
+

Gestion des utilisateurs

+

Liste des comptes webapp et attribution des rôles

+
+
+ +
+
+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, msg in messages %} +
+ {{ msg }} +
+ {% endfor %} +
+ {% endif %} +{% endwith %} + +
+ {% if users %} +
+ + + + + + + + + + + + {% for u in users %} + + + + + + + + {% endfor %} + +
+ Utilisateur + + E-mail + + Rôle actuel + + Inscrit le + + Modifier le rôle +
+
+
+ {{ u.username[0].upper() }} +
+
+
{{ u.username }}
+ {% if u.id == current_user.id %} + C'est vous + {% endif %} +
+
+
+ {{ u.email }} + + {% 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 %} + + + {{ role_labels.get(u.role, u.role) }} + + {% else %} + + {{ role_labels.get(u.role, u.role) }} + + {% endif %} + + + {% if u.created_at %}{{ u.created_at.strftime('%d/%m/%Y à %H:%M') }}{% else %}—{% endif %} + + +
+
+ + +
+ {% if u.id != current_user.id %} +
+ +
+ {% endif %} +
+ {% if u.id == current_user.id %} +

Protection: modification/suppression impossible

+ {% endif %} +
+
+ {% else %} +
+ + + +

Aucun utilisateur enregistré

+
+ {% endif %} +
+ + +{% if users %} +
+

+ + + + Hiérarchie des rôles +

+
+ {% for role_name in roles %} +
+
+ + {{ role_labels.get(role_name, role_name) }} +
+

+ {% 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 %} +

+
+ {% endfor %} +
+
+

+ 💡 Conseil : Vous pouvez personnaliser les rôles et leurs permissions dans la page + Paramètres. +

+
+
+{% endif %} + + + + + +{% endblock %} diff --git a/webapp/templates/youtube.html b/webapp/templates/youtube.html index e36fdef..bd9bb15 100644 --- a/webapp/templates/youtube.html +++ b/webapp/templates/youtube.html @@ -1,153 +1,264 @@ {% extends "template.html" %} {% block content %} -

Notifications YouTube

- -{% if msg %} -
- {{ msg }} +
+

Notifications YouTube

+ + {% if msg %} +
+ {{ msg }} +
+ + {% endif %} + +
+

+ 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. +

+
- -{% endif %} - -

- 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. -

{% if not notification %} -

Notifications

- - - - - - - - - - - - {% for notification in notifications %} - - - - - - - - {% endfor %} - -
Chaîne YouTubeCanal DiscordTypeMessage#
{{notification.channel_id}}{{notification.notify_channel_name}} - {% if notification.video_type == 'all' %} - Toutes - {% elif notification.video_type == 'video' %} - Vidéos uniquement - {% elif notification.video_type == 'short' %} - Shorts uniquement - {% endif %} - {{notification.message}} - {{ '✅' if notification.enable else '❌' }} - - 🗑 -
+
+

Notifications configurées

+
+
+ + + + + + + + + + + + {% for notification in notifications %} + + + + + + + + {% else %} + + + + {% endfor %} + +
Chaîne YouTubeCanal DiscordTypeMessageActions
+ {{ notification.channel_id }} + {{ notification.notify_channel_name }} + + {% if notification.video_type == 'all' %}Toutes + {% elif notification.video_type == 'video' %}Vidéos + {% else %}Shorts{% endif %} + + {{ notification.message }} + +
+ Aucune notification configurée. Ajoutez-en une ci-dessous. +
+
+
+
{% endif %} -

{{ 'Editer une notification' if notification else 'Ajouter une notification YouTube' }}

+
+

+ {{ 'Modifier la notification' if notification else 'Ajouter une notification YouTube' }} +

-
-
-
-
- Configuration de base - - - - - - - - - - - -
+
+
+ +
+

Configuration de base

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
-
- Personnalisation de l'embed Discord - - - - Variables: {video_title}, {channel_name}, {video_url}, {video_id} - - - - Variables: {video_title}, {channel_name}, {video_url}, {published_at}, {is_short} - - - - - Format: FF0000 (rouge YouTube par défaut) - - - - - - - - - - - - - -
+
+

Personnalisation de l'embed Discord

+ +
+ + +

Variables: {video_title}, {channel_name}, {video_url}, {video_id}

+
+ +
+ + +
+ +
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
- - -
- -
-

Prévisualisation de l'embed Discord

-
-
- - Nom de la chaîne -
- Titre de la vidéo -
-
- -
-
- -
- +
+ + {% if notification %} + + Annuler + + {% endif %} +
+ +
+ +
+

Prévisualisation de l'embed Discord

+
+
+ + Nom de la chaîne +
+ Titre de la vidéo +
+
+ +
+
+ +
+ +
+

Cette prévisualisation est approximative.

+ +
+

Variables disponibles

+
    +
  • {channel_name} — Nom de la chaîne
  • +
  • {video_title} — Titre de la vidéo
  • +
  • {video_url} — Lien vers la vidéo
  • +
  • {video_id} — ID de la vidéo
  • +
  • {thumbnail} — URL de la miniature
  • +
  • {published_at} — Date de publication
  • +
  • {is_short} — True si c'est un short
  • +
+
- Cette prévisualisation est approximative. L'apparence réelle sur Discord peut varier légèrement.
@@ -228,18 +339,4 @@ updatePreview(); - -

- Variables disponibles pour l'embed : -

    -
  • {channel_name} : nom de la chaîne YouTube
  • -
  • {video_title} : titre de la vidéo
  • -
  • {video_url} : lien vers la vidéo
  • -
  • {video_id} : ID de la vidéo
  • -
  • {thumbnail} : URL de la miniature
  • -
  • {published_at} : date de publication
  • -
  • {is_short} : True si c'est un short, False sinon
  • -
-

- {% endblock %} diff --git a/webapp/twitch_auth.py b/webapp/twitch_auth.py index 4994f81..9fbd100 100644 --- a/webapp/twitch_auth.py +++ b/webapp/twitch_auth.py @@ -1,50 +1,71 @@ +import asyncio import logging -from flask import render_template, request, redirect, url_for +from flask import render_template, request, redirect, url_for, flash from twitchAPI.twitch import Twitch -from twitchAPI.type import TwitchAPIException +from twitchAPI.type import TwitchAPIException from twitchAPI.oauth import UserAuthenticator from database import db from database.helpers import ConfigurationHelper from twitchbot import USER_SCOPE from webapp import webapp +from webapp.auth import require_page auth: UserAuthenticator -@webapp.route("/configurations/twitch/help") -def twitchConfigurationHelp(): - return render_template("twitch-aide.html", token_redirect_url = _buildUrl()) -@webapp.route("/configurations/twitch/request-token") -async def twitchRequestToken(): +@webapp.route("/configurations/twitch/help") +@require_page("configurations") +def twitchConfigurationHelp(): + return render_template("twitch-aide.html", token_redirect_url=_buildUrl()) + + +@webapp.route("/configurations/twitch/request-token") +@require_page("configurations") +def twitchRequestToken(): global auth helper = ConfigurationHelper() - twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret')) + twitch = asyncio.run(Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))) auth = UserAuthenticator(twitch, USER_SCOPE, url=_buildUrl()) return redirect(auth.return_auth_url()) -@webapp.route("/configurations/twitch/receive-token") -async def twitchReceiveToken(): + +@webapp.route("/configurations/twitch/receive-token") +def twitchReceiveToken(): global auth state = request.args.get('state') code = request.args.get('code') - if state != auth.state : - logging('bad returned state') + + logging.info("Callback Twitch reçu - state: %s, code: %s", state, code is not None) + + if not hasattr(auth, 'state') or auth is None: + logging.error('Objet auth non initialisé - veuillez réessayer') return redirect(url_for('openConfigurations')) - if code == None : - logging('no returned state') + + if state != auth.state: + logging.error('State invalide - attendu: %s, reçu: %s', auth.state, state) return redirect(url_for('openConfigurations')) - + if code is None: + logging.error('Pas de code retourné par Twitch') + return redirect(url_for('openConfigurations')) + try: - token, refresh = await auth.authenticate(user_token=code) + token, refresh = asyncio.run(auth.authenticate(user_token=code)) + logging.info('Tokens Twitch obtenus avec succès') helper = ConfigurationHelper() helper.createOrUpdate('twitch_access_token', token) helper.createOrUpdate('twitch_refresh_token', refresh) db.session.commit() + logging.info('Tokens Twitch sauvegardés en base de données') + flash('Token Twitch enregistré. Redémarrez l\'application pour que le bot utilise le nouveau token.', 'success') except TwitchAPIException as e: - logging(e) + logging.error('Erreur API Twitch: %s', e) + flash(f'Erreur API Twitch : {e}', 'error') + except Exception as e: + logging.error('Erreur inattendue: %s', e) + flash(f'Erreur inattendue : {e}', 'error') return redirect(url_for('openConfigurations')) # hack pas fou mais on estime qu'on sera toujours en ssl en connecté diff --git a/webapp/twitch_events.py b/webapp/twitch_events.py new file mode 100644 index 0000000..41db9cf --- /dev/null +++ b/webapp/twitch_events.py @@ -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/") +@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")) diff --git a/webapp/twitch_moderation.py b/webapp/twitch_moderation.py new file mode 100644 index 0000000..75bcb80 --- /dev/null +++ b/webapp/twitch_moderation.py @@ -0,0 +1,403 @@ +from flask import render_template, request, redirect, url_for, jsonify +from webapp import webapp +from webapp.auth import require_page, can_write_page +from database import db +from database.models import Commande, TwitchModerationLog, TwitchLinkFilter, TwitchBannedWord +from database.helpers import ConfigurationHelper +from datetime import datetime, timedelta +import asyncio + +MODERATION_COMMANDS = [ + { + "commands": ["!kick", "!to", "!timeout", "!tm"], + "usage": "!timeout [minutes] [raison]", + "description": "Ejection temporaire d'un viewer (3 minutes par defaut) avec raison optionnelle", + "permission": "Moderateur" + }, + { + "commands": ["!ban"], + "usage": "!ban [viewer2] ...", + "description": "Bannissement d'un ou plusieurs viewers (max 5)", + "permission": "Moderateur" + }, + { + "commands": ["!unban"], + "usage": "!unban [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 ", + "description": "Active/desactive le mode Shield de Twitch", + "permission": "Moderateur" + }, + { + "commands": ["!settitle"], + "usage": "!settitle ", + "description": "Changement du titre du live", + "permission": "Moderateur" + }, + { + "commands": ["!setgame", "!setcateg"], + "usage": "!setgame ", + "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 ", + "description": "Activer/desactiver/inverser une liste d'annonce par alias", + "permission": "Moderateur" + }, + { + "commands": ["!no_game"], + "usage": "!no_game ", + "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 [minutes]", + "description": "Autorise temporairement un viewer a poster un lien (1 minute par defaut)", + "permission": "Moderateur" + }, +] + +TWITCH_PERMISSIONS = {'viewer': 'Tous (viewers)', 'sub': 'Abonnés', 'vip': 'VIP', 'moderator': 'Modérateur'} + + +@webapp.route("/twitch-moderation") +@require_page("twitch_moderation") +def twitch_moderation(): + custom_commands = Commande.query.filter_by(twitch_enable=True).all() + logs = TwitchModerationLog.query.order_by(TwitchModerationLog.created_at.desc()).limit(50).all() + raw_channel = ConfigurationHelper().getValue("twitch_channel") or webapp.config["BOT_STATUS"].get("twitch_channel_name") or "chainesteve" + twitch_channel = (raw_channel or "").strip().lower() or "chainesteve" + embed_parent = request.host or "localhost" + + # Link filter status + link_filter_config = TwitchLinkFilter.query.first() + link_filter_enabled = link_filter_config.enabled if link_filter_config else False + + # Banned words + banned_words = TwitchBannedWord.query.filter_by(enabled=True).all() + + # Live status (from BOT_STATUS) + bot_status = webapp.config.get("BOT_STATUS", {}) + is_live = bot_status.get("twitch_is_live", False) + viewer_count = bot_status.get("twitch_viewer_count", 0) + + return render_template( + "twitch-moderation.html", + commands=MODERATION_COMMANDS, + custom_commands=custom_commands, + logs=logs, + twitch_permissions=TWITCH_PERMISSIONS, + twitch_channel=twitch_channel, + embed_parent=embed_parent, + link_filter_enabled=link_filter_enabled, + banned_words=banned_words, + is_live=is_live, + viewer_count=viewer_count, + ) + +@webapp.route("/twitch-moderation/logs/clear") +@require_page("twitch_moderation") +def clear_twitch_logs(): + if not can_write_page("twitch_moderation"): + return render_template("403.html"), 403 + TwitchModerationLog.query.delete() + db.session.commit() + return redirect(url_for('twitch_moderation')) + +@webapp.route("/twitch-moderation/add", methods=['POST']) +@require_page("twitch_moderation") +def add_twitch_commande(): + if not can_write_page("twitch_moderation"): + return render_template("403.html"), 403 + trigger = request.form.get('trigger') + response = request.form.get('response') + twitch_permission = request.form.get('twitch_permission') or 'viewer' + if twitch_permission not in TWITCH_PERMISSIONS: + twitch_permission = 'viewer' + + if trigger and response: + if not trigger.startswith('!'): + trigger = '!' + trigger + + existing = Commande.query.filter_by(trigger=trigger).first() + if not existing: + commande = Commande(trigger=trigger, response=response, discord_enable=False, twitch_enable=True, twitch_permission=twitch_permission) + db.session.add(commande) + db.session.commit() + + return redirect(url_for('twitch_moderation')) + +@webapp.route("/twitch-moderation/banned-word/add", methods=['POST']) +@require_page("twitch_moderation") +def add_banned_word(): + if not can_write_page("twitch_moderation"): + return render_template("403.html"), 403 + + word = request.form.get('word', '').strip().lower() + timeout_duration = int(request.form.get('timeout_duration', 60)) + + if word: + existing = TwitchBannedWord.query.filter_by(word=word).first() + if not existing: + banned_word = TwitchBannedWord(word=word, enabled=True, timeout_duration=timeout_duration) + db.session.add(banned_word) + db.session.commit() + + return redirect(url_for('twitch_moderation')) + +@webapp.route("/twitch-moderation/banned-word/delete/") +@require_page("twitch_moderation") +def delete_banned_word(word_id): + if not can_write_page("twitch_moderation"): + return render_template("403.html"), 403 + + banned_word = TwitchBannedWord.query.get_or_404(word_id) + db.session.delete(banned_word) + db.session.commit() + + return redirect(url_for('twitch_moderation')) + +@webapp.route("/twitch-moderation/send-message", methods=['POST']) +@require_page("twitch_moderation") +def send_twitch_message(): + if not can_write_page("twitch_moderation"): + return jsonify({"success": False, "error": "Permission refusée"}), 403 + + data = request.get_json() + message = data.get('message', '').strip() + + if not message: + return jsonify({"success": False, "error": "Message vide"}), 400 + + # Vérifier que le bot Twitch est connecté + from twitchbot import twitchBot + if not hasattr(twitchBot, 'chat') or not twitchBot.chat: + return jsonify({"success": False, "error": "Bot Twitch non connecté"}), 503 + + # Récupérer le nom du channel + channel = ConfigurationHelper().getValue('twitch_channel') + if not channel: + return jsonify({"success": False, "error": "Channel Twitch non configuré"}), 400 + + # Envoyer le message de manière asynchrone + try: + async def send_msg(): + try: + await twitchBot.chat.send_message(channel, message) + return True + except Exception as e: + return str(e) + + # Exécuter la coroutine de manière synchrone + loop = asyncio.new_event_loop() + result = loop.run_until_complete(send_msg()) + loop.close() + + if result is True: + return jsonify({"success": True}) + else: + return jsonify({"success": False, "error": f"Erreur: {result}"}), 500 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + +@webapp.route("/twitch-moderation/messages") +@require_page("twitch_moderation") +def get_twitch_messages(): + """Retourne les derniers messages du chat Twitch""" + messages = webapp.config["BOT_STATUS"].get("twitch_chat_messages", []) + return jsonify({"messages": messages}) + +@webapp.route("/twitch-moderation/execute-action", methods=['POST']) +@require_page("twitch_moderation") +def execute_moderation_action(): + """Exécute une action de modération directement""" + if not can_write_page("twitch_moderation"): + return jsonify({"success": False, "error": "Permission refusée"}), 403 + + data = request.get_json() + action = data.get('action', '').strip() + params = data.get('params', {}) + + if not action: + return jsonify({"success": False, "error": "Action non spécifiée"}), 400 + + # Vérifier que le bot Twitch est connecté + from twitchbot import twitchBot + if not hasattr(twitchBot, 'chat') or not twitchBot.chat or not hasattr(twitchBot, 'twitch'): + return jsonify({"success": False, "error": "Bot Twitch non connecté"}), 503 + + # Récupérer le nom du channel + channel = ConfigurationHelper().getValue('twitch_channel') + if not channel: + return jsonify({"success": False, "error": "Channel Twitch non configuré"}), 400 + + # Créer un objet ChatMessage simulé pour les commandes qui en ont besoin + from twitchAPI.chat import ChatMessage + from types import SimpleNamespace + + # Exécuter l'action de manière asynchrone + try: + async def execute_action(): + try: + if action == 'timeout': + from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action + username = params.get('username', '').strip().lstrip('@') + duration = int(params.get('duration', 600)) # en secondes + reason = params.get('reason', 'Timeout') + + broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel) + moderator_id = await _get_moderator_id(twitchBot.twitch) + user_id = await _get_user_id(twitchBot.twitch, username) + + if user_id: + await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason, duration=duration) + _log_action("timeout", "WebApp", username, f"{duration}s - {reason}") + return {"success": True, "message": f"Timeout de {username} pour {duration}s"} + return {"success": False, "error": f"Utilisateur {username} introuvable"} + + elif action == 'ban': + from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action + username = params.get('username', '').strip().lstrip('@') + reason = params.get('reason', 'Ban') + + broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel) + moderator_id = await _get_moderator_id(twitchBot.twitch) + user_id = await _get_user_id(twitchBot.twitch, username) + + if user_id: + await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason) + _log_action("ban", "WebApp", username, reason) + return {"success": True, "message": f"Ban de {username}"} + return {"success": False, "error": f"Utilisateur {username} introuvable"} + + elif action == 'clean': + from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action + username = params.get('username', '').strip().lstrip('@') + + broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel) + moderator_id = await _get_moderator_id(twitchBot.twitch) + + if username: + user_id = await _get_user_id(twitchBot.twitch, username) + if user_id: + await twitchBot.twitch.delete_chat_messages(broadcaster_id, moderator_id, user_id=user_id) + _log_action("clean", "WebApp", username) + return {"success": True, "message": f"Messages de {username} supprimés"} + return {"success": False, "error": f"Utilisateur {username} introuvable"} + else: + await twitchBot.twitch.delete_chat_messages(broadcaster_id, moderator_id) + _log_action("clean", "WebApp", None, "Chat complet") + return {"success": True, "message": "Chat nettoyé"} + + elif action == 'permit': + from database.models import TwitchPermit + username = params.get('username', '').strip().lstrip('@').lower() + duration = int(params.get('duration', 60)) # en secondes + + expires_at = datetime.now() + timedelta(seconds=duration) + + with webapp.app_context(): + existing = TwitchPermit.query.filter_by(username=username).first() + if existing: + existing.expires_at = expires_at + else: + permit = TwitchPermit(username=username, expires_at=expires_at) + db.session.add(permit) + db.session.commit() + + return {"success": True, "message": f"Permit accordé à {username} pour {duration//60}min"} + + elif action in ['subon', 'suboff', 'emoteon', 'emoteoff']: + from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _log_action + + broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel) + moderator_id = await _get_moderator_id(twitchBot.twitch) + + if action == 'subon': + await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=True) + _log_action("subon", "WebApp") + return {"success": True, "message": "Mode abonnés activé"} + elif action == 'suboff': + await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=False) + _log_action("suboff", "WebApp") + return {"success": True, "message": "Mode abonnés désactivé"} + elif action == 'emoteon': + await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=True) + _log_action("emoteon", "WebApp") + return {"success": True, "message": "Mode emote activé"} + elif action == 'emoteoff': + await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=False) + _log_action("emoteoff", "WebApp") + return {"success": True, "message": "Mode emote désactivé"} + + return {"success": False, "error": f"Action '{action}' non reconnue"} + + except Exception as e: + import logging + logging.error(f"Erreur lors de l'exécution de l'action {action}: {e}") + return {"success": False, "error": str(e)} + + # Exécuter la coroutine de manière synchrone + loop = asyncio.new_event_loop() + result = loop.run_until_complete(execute_action()) + loop.close() + + return jsonify(result) + + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 diff --git a/webapp/users.py b/webapp/users.py new file mode 100644 index 0000000..ab5a169 --- /dev/null +++ b/webapp/users.py @@ -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/", 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/", 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")) diff --git a/webapp/youtube.py b/webapp/youtube.py index 6f8ae7b..8d156d4 100644 --- a/webapp/youtube.py +++ b/webapp/youtube.py @@ -2,8 +2,8 @@ import re import requests from urllib.parse import urlencode from flask import render_template, request, redirect, url_for - from webapp import webapp +from webapp.auth import require_page, can_write_page from database import db from database.models import YouTubeNotification from discordbot import bot @@ -64,6 +64,7 @@ def _get_channel_id_from_handle(handle: str) -> str: @webapp.route("/youtube") +@require_page("youtube") def openYouTube(): notifications: list[YouTubeNotification] = YouTubeNotification.query.all() channels = bot.getAllTextChannel() @@ -77,7 +78,10 @@ def openYouTube(): @webapp.route("/youtube/add", methods=['POST']) +@require_page("youtube") def addYouTube(): + if not can_write_page("youtube"): + return render_template("403.html"), 403 channel_input = request.form.get('channel_id', '').strip() channel_id = extract_channel_id(channel_input) @@ -118,7 +122,10 @@ def addYouTube(): @webapp.route("/youtube/toggle/") +@require_page("youtube") def toggleYouTube(id): + if not can_write_page("youtube"): + return render_template("403.html"), 403 notification: YouTubeNotification = YouTubeNotification.query.get_or_404(id) notification.enable = not notification.enable db.session.commit() @@ -126,6 +133,7 @@ def toggleYouTube(id): @webapp.route("/youtube/edit/") +@require_page("youtube") def openEditYouTube(id): notification = YouTubeNotification.query.get_or_404(id) channels = bot.getAllTextChannel() @@ -135,7 +143,10 @@ def openEditYouTube(id): @webapp.route("/youtube/edit/", methods=['POST']) +@require_page("youtube") def submitEditYouTube(id): + if not can_write_page("youtube"): + return render_template("403.html"), 403 notification: YouTubeNotification = YouTubeNotification.query.get_or_404(id) channel_input = request.form.get('channel_id', '').strip() @@ -174,7 +185,10 @@ def submitEditYouTube(id): @webapp.route("/youtube/del/") +@require_page("youtube") def delYouTube(id): + if not can_write_page("youtube"): + return render_template("403.html"), 403 notification = YouTubeNotification.query.get_or_404(id) db.session.delete(notification) db.session.commit()