Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e40f427f | ||
|
|
b49f914501 | ||
|
|
42db84f010 |
@@ -22,11 +22,13 @@ RUN sed -i 's/# fr_FR.UTF-8 UTF-8/fr_FR.UTF-8 UTF-8/' /etc/locale.gen; \
|
||||
|
||||
COPY requirements.txt .
|
||||
COPY run-web.py .
|
||||
COPY shared_stats.py .
|
||||
COPY ./database ./database
|
||||
COPY ./discordbot ./discordbot
|
||||
COPY ./protondb ./protondb
|
||||
COPY ./webapp ./webapp
|
||||
COPY ./twitchbot ./twitchbot
|
||||
COPY ./youtubebot ./youtubebot
|
||||
COPY start.sh /start.sh
|
||||
|
||||
RUN python3 -m venv /app/venv && \
|
||||
|
||||
@@ -61,3 +61,47 @@ class AntiCheatCache(db.Model):
|
||||
notes = db.Column(db.String(1024))
|
||||
updated_at = db.Column(db.DateTime)
|
||||
|
||||
class FreeGame(db.Model):
|
||||
__tablename__ = 'free_game'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
entry_id = db.Column(db.String(512), unique=True)
|
||||
title = db.Column(db.String(512))
|
||||
source = db.Column(db.String(64))
|
||||
url = db.Column(db.String(2048))
|
||||
image_url = db.Column(db.String(2048))
|
||||
description = db.Column(db.Text)
|
||||
valid_from = db.Column(db.DateTime)
|
||||
valid_to = db.Column(db.DateTime)
|
||||
notified = db.Column(db.Boolean, default=False)
|
||||
notified_at = db.Column(db.DateTime)
|
||||
created_at = db.Column(db.DateTime)
|
||||
|
||||
class DiscordInvite(db.Model):
|
||||
__tablename__ = 'discord_invite'
|
||||
code = db.Column(db.String(32), primary_key=True)
|
||||
guild_id = db.Column(db.String(64), nullable=False)
|
||||
channel_id = db.Column(db.String(64), nullable=False)
|
||||
channel_name = db.Column(db.String(256))
|
||||
inviter_id = db.Column(db.String(64))
|
||||
inviter_name = db.Column(db.String(256))
|
||||
uses = db.Column(db.Integer, default=0)
|
||||
max_uses = db.Column(db.Integer, default=0)
|
||||
max_age = db.Column(db.Integer, default=0)
|
||||
temporary = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime)
|
||||
expires_at = db.Column(db.DateTime)
|
||||
revoked = db.Column(db.Boolean, default=False)
|
||||
last_sync = db.Column(db.DateTime)
|
||||
|
||||
class YoutubeAlert(db.Model):
|
||||
__tablename__ = 'youtube_alert'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
enable = db.Column(db.Boolean, default=True)
|
||||
channel_id = db.Column(db.String(64), unique=True)
|
||||
channel_name = db.Column(db.String(256))
|
||||
notify_channel = db.Column(db.Integer)
|
||||
message = db.Column(db.String(2000))
|
||||
last_video_id = db.Column(db.String(64))
|
||||
last_video_title = db.Column(db.String(512))
|
||||
last_video_date = db.Column(db.DateTime)
|
||||
|
||||
|
||||
@@ -76,3 +76,47 @@ CREATE TABLE IF NOT EXISTS `member_invites` (
|
||||
`inviter_name` VARCHAR(256),
|
||||
`join_date` DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `free_game` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`entry_id` VARCHAR(512) UNIQUE NOT NULL,
|
||||
`title` VARCHAR(512) NOT NULL,
|
||||
`source` VARCHAR(64) NOT NULL,
|
||||
`url` VARCHAR(2048) NOT NULL,
|
||||
`image_url` VARCHAR(2048),
|
||||
`description` TEXT,
|
||||
`valid_from` DATETIME,
|
||||
`valid_to` DATETIME,
|
||||
`notified` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`notified_at` DATETIME,
|
||||
`created_at` DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `discord_invite` (
|
||||
`code` VARCHAR(32) PRIMARY KEY,
|
||||
`guild_id` VARCHAR(64) NOT NULL,
|
||||
`channel_id` VARCHAR(64) NOT NULL,
|
||||
`channel_name` VARCHAR(256),
|
||||
`inviter_id` VARCHAR(64),
|
||||
`inviter_name` VARCHAR(256),
|
||||
`uses` INTEGER NOT NULL DEFAULT 0,
|
||||
`max_uses` INTEGER NOT NULL DEFAULT 0,
|
||||
`max_age` INTEGER NOT NULL DEFAULT 0,
|
||||
`temporary` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`created_at` DATETIME,
|
||||
`expires_at` DATETIME,
|
||||
`revoked` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
`last_sync` DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `youtube_alert` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`enable` BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
`channel_id` VARCHAR(64) UNIQUE NOT NULL,
|
||||
`channel_name` VARCHAR(256),
|
||||
`notify_channel` INTEGER NOT NULL,
|
||||
`message` VARCHAR(2000) NOT NULL,
|
||||
`last_video_id` VARCHAR(64),
|
||||
`last_video_title` VARCHAR(512),
|
||||
`last_video_date` DATETIME
|
||||
);
|
||||
|
||||
+236
-3
@@ -5,9 +5,11 @@ import random
|
||||
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import Configuration, Humeur, Commande
|
||||
from database.models import Configuration, Humeur, Commande, DiscordInvite
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from discord import Message, TextChannel, Member
|
||||
from discordbot.humblebundle import checkHumbleBundleAndNotify
|
||||
from discordbot.freegames import checkFreeGamesAndNotify
|
||||
from discordbot.moderation import (
|
||||
handle_warning_command,
|
||||
handle_remove_warning_command,
|
||||
@@ -22,7 +24,9 @@ from discordbot.moderation import (
|
||||
handle_say_command
|
||||
)
|
||||
from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
|
||||
from discordbot.autorole import assignAutoRole
|
||||
from protondb import searhProtonDb
|
||||
from shared_stats import stats_manager, discord_bridge
|
||||
|
||||
class DiscordBot(discord.Client):
|
||||
async def on_ready(self):
|
||||
@@ -33,8 +37,54 @@ class DiscordBot(discord.Client):
|
||||
for guild in self.guilds:
|
||||
await updateInviteCache(guild)
|
||||
|
||||
# Enregistrer le bot dans le bridge pour communication avec Flask
|
||||
discord_bridge.register_bot(self, self.loop)
|
||||
|
||||
# Mise à jour des stats partagées
|
||||
self._update_shared_stats()
|
||||
|
||||
# Synchronisation initiale des invitations
|
||||
await self.syncInvites()
|
||||
|
||||
self.loop.create_task(self.updateStatus())
|
||||
self.loop.create_task(self.updateHumbleBundle())
|
||||
self.loop.create_task(self.updateFreeGames())
|
||||
self.loop.create_task(self.updateYoutubeAlerts())
|
||||
self.loop.create_task(self._periodic_stats_update())
|
||||
|
||||
def _update_shared_stats(self):
|
||||
"""Met à jour les statistiques partagées"""
|
||||
total_members = sum(g.member_count or 0 for g in self.guilds)
|
||||
total_channels = len(list(self.get_all_channels()))
|
||||
|
||||
stats_manager.update_discord_stats(
|
||||
connected=True,
|
||||
guilds=len(self.guilds),
|
||||
members=total_members,
|
||||
channels=total_channels,
|
||||
bot_name=str(self.user),
|
||||
bot_id=self.user.id
|
||||
)
|
||||
|
||||
# Mise à jour des cogs/fonctionnalités activées
|
||||
helper = ConfigurationHelper()
|
||||
cogs = {
|
||||
'Modération': helper.getValue('moderation_enable') or False,
|
||||
'Ban': helper.getValue('moderation_ban_enable') or False,
|
||||
'Kick': helper.getValue('moderation_kick_enable') or False,
|
||||
'ProtonDB': helper.getValue('proton_db_enable_enable') or False,
|
||||
'Humeurs': True, # Toujours actif si le bot tourne
|
||||
'Jeux Gratuits': helper.getValue('freegames_enable') or False,
|
||||
'Messages de bienvenue': helper.getValue('welcome_enable') or False,
|
||||
'Auto-Role': helper.getValue('autorole_enable') or False,
|
||||
}
|
||||
stats_manager.update_cogs(cogs)
|
||||
|
||||
async def _periodic_stats_update(self):
|
||||
"""Met à jour les stats périodiquement"""
|
||||
while not self.is_closed():
|
||||
await asyncio.sleep(60) # Toutes les minutes
|
||||
self._update_shared_stats()
|
||||
|
||||
async def updateStatus(self):
|
||||
while not self.is_closed():
|
||||
@@ -42,8 +92,18 @@ class DiscordBot(discord.Client):
|
||||
if len(humeurs)>0 :
|
||||
humeur = random.choice(humeurs)
|
||||
if humeur != None:
|
||||
logging.info(f'Changement de statut : {humeur.text}')
|
||||
await self.change_presence(status = discord.Status.online, activity = discord.CustomActivity(humeur.text))
|
||||
# Récupérer les stats pour les variables
|
||||
total_members = sum(g.member_count or 0 for g in self.guilds)
|
||||
total_channels = len(list(self.get_all_channels()))
|
||||
|
||||
# Remplacer les variables dans le texte
|
||||
status_text = humeur.text
|
||||
status_text = status_text.replace('{servers}', str(len(self.guilds)))
|
||||
status_text = status_text.replace('{members}', str(total_members))
|
||||
status_text = status_text.replace('{channels}', str(total_channels))
|
||||
|
||||
logging.info(f'Changement de statut : {status_text}')
|
||||
await self.change_presence(status = discord.Status.online, activity = discord.CustomActivity(status_text))
|
||||
await asyncio.sleep(10*60)
|
||||
|
||||
async def updateHumbleBundle(self):
|
||||
@@ -51,6 +111,17 @@ class DiscordBot(discord.Client):
|
||||
await checkHumbleBundleAndNotify(self)
|
||||
await asyncio.sleep(30*60)
|
||||
|
||||
async def updateFreeGames(self):
|
||||
while not self.is_closed():
|
||||
await checkFreeGamesAndNotify(self)
|
||||
await asyncio.sleep(60*60) # Vérification toutes les heures
|
||||
|
||||
async def updateYoutubeAlerts(self):
|
||||
from youtubebot.youtube_alert import checkNewVideos
|
||||
while not self.is_closed():
|
||||
await checkNewVideos(self)
|
||||
await asyncio.sleep(10*60) # Vérification toutes les 10 minutes
|
||||
|
||||
def getAllTextChannel(self) -> list[TextChannel]:
|
||||
channels = []
|
||||
for channel in self.get_all_channels():
|
||||
@@ -72,7 +143,156 @@ class DiscordBot(discord.Client):
|
||||
'roles': roles
|
||||
})
|
||||
return guilds_roles
|
||||
|
||||
def getAllGuilds(self):
|
||||
"""Retourne la liste de tous les serveurs Discord"""
|
||||
guilds_list = []
|
||||
for guild in self.guilds:
|
||||
guilds_list.append({
|
||||
'id': guild.id,
|
||||
'name': guild.name,
|
||||
'member_count': guild.member_count,
|
||||
'icon_url': str(guild.icon.url) if guild.icon else None,
|
||||
'owner_id': guild.owner_id
|
||||
})
|
||||
return guilds_list
|
||||
|
||||
async def leaveGuild(self, guild_id: int) -> bool:
|
||||
"""Quitte un serveur Discord par son ID"""
|
||||
guild = self.get_guild(guild_id)
|
||||
if guild:
|
||||
await guild.leave()
|
||||
logging.info(f'Le bot a quitté le serveur : {guild.name} (ID: {guild_id})')
|
||||
return True
|
||||
return False
|
||||
|
||||
async def syncInvites(self, guild_id: int = None) -> dict:
|
||||
"""Synchronise les invitations Discord avec la base de données"""
|
||||
result = {'synced': 0, 'errors': [], 'guilds': []}
|
||||
|
||||
guilds_to_sync = [self.get_guild(guild_id)] if guild_id else self.guilds
|
||||
|
||||
for guild in guilds_to_sync:
|
||||
if not guild:
|
||||
continue
|
||||
|
||||
try:
|
||||
invites = await guild.invites()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Marquer les invitations existantes comme révoquées (on les réactivera si elles existent encore)
|
||||
existing_invites = DiscordInvite.query.filter_by(guild_id=str(guild.id), revoked=False).all()
|
||||
existing_codes = {inv.code for inv in existing_invites}
|
||||
current_codes = {inv.code for inv in invites}
|
||||
|
||||
# Marquer comme révoquées celles qui n'existent plus
|
||||
for inv in existing_invites:
|
||||
if inv.code not in current_codes:
|
||||
inv.revoked = True
|
||||
inv.last_sync = now
|
||||
|
||||
for invite in invites:
|
||||
# Calculer la date d'expiration
|
||||
expires_at = None
|
||||
if invite.max_age and invite.max_age > 0 and invite.created_at:
|
||||
expires_at = invite.created_at + timedelta(seconds=invite.max_age)
|
||||
|
||||
# Chercher si l'invitation existe déjà
|
||||
db_invite = DiscordInvite.query.filter_by(code=invite.code).first()
|
||||
|
||||
if db_invite:
|
||||
# Mettre à jour l'invitation existante
|
||||
db_invite.uses = invite.uses or 0
|
||||
db_invite.revoked = False
|
||||
db_invite.last_sync = now
|
||||
db_invite.channel_name = invite.channel.name if invite.channel else None
|
||||
db_invite.inviter_name = invite.inviter.name if invite.inviter else None
|
||||
else:
|
||||
# Créer une nouvelle invitation
|
||||
db_invite = DiscordInvite(
|
||||
code=invite.code,
|
||||
guild_id=str(guild.id),
|
||||
channel_id=str(invite.channel.id) if invite.channel else '',
|
||||
channel_name=invite.channel.name if invite.channel else None,
|
||||
inviter_id=str(invite.inviter.id) if invite.inviter else None,
|
||||
inviter_name=invite.inviter.name if invite.inviter else None,
|
||||
uses=invite.uses or 0,
|
||||
max_uses=invite.max_uses or 0,
|
||||
max_age=invite.max_age or 0,
|
||||
temporary=invite.temporary or False,
|
||||
created_at=invite.created_at,
|
||||
expires_at=expires_at,
|
||||
revoked=False,
|
||||
last_sync=now
|
||||
)
|
||||
db.session.add(db_invite)
|
||||
|
||||
result['synced'] += 1
|
||||
|
||||
db.session.commit()
|
||||
result['guilds'].append({'id': guild.id, 'name': guild.name, 'invites': len(invites)})
|
||||
logging.info(f'Invitations synchronisées pour {guild.name}: {len(invites)} invitations')
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur lors de la synchronisation des invitations pour {guild.name}: {e}')
|
||||
result['errors'].append(f'{guild.name}: {str(e)}')
|
||||
|
||||
return result
|
||||
|
||||
async def revokeInvite(self, invite_code: str) -> dict:
|
||||
"""Révoque une invitation Discord"""
|
||||
result = {'success': False, 'message': '', 'invite_code': invite_code}
|
||||
|
||||
try:
|
||||
# Chercher l'invitation dans la BDD
|
||||
db_invite = DiscordInvite.query.filter_by(code=invite_code).first()
|
||||
if not db_invite:
|
||||
result['message'] = 'Invitation non trouvée dans la base de données'
|
||||
return result
|
||||
|
||||
# Récupérer le guild
|
||||
guild = self.get_guild(int(db_invite.guild_id))
|
||||
if not guild:
|
||||
result['message'] = 'Serveur Discord non trouvé'
|
||||
return result
|
||||
|
||||
# Chercher l'invitation sur Discord
|
||||
try:
|
||||
invites = await guild.invites()
|
||||
discord_invite = next((inv for inv in invites if inv.code == invite_code), None)
|
||||
|
||||
if discord_invite:
|
||||
await discord_invite.delete(reason='Révoquée via interface web')
|
||||
logging.info(f'Invitation {invite_code} révoquée sur Discord')
|
||||
|
||||
except Exception as e:
|
||||
logging.warning(f'Impossible de révoquer l\'invitation sur Discord: {e}')
|
||||
|
||||
# Marquer comme révoquée dans la BDD
|
||||
db_invite.revoked = True
|
||||
db_invite.last_sync = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
|
||||
result['success'] = True
|
||||
result['message'] = f'Invitation {invite_code} révoquée avec succès'
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur lors de la révocation de l\'invitation {invite_code}: {e}')
|
||||
result['message'] = f'Erreur: {str(e)}'
|
||||
|
||||
return result
|
||||
|
||||
def getInvites(self, guild_id: int = None, include_revoked: bool = False) -> list:
|
||||
"""Récupère les invitations depuis la base de données"""
|
||||
query = DiscordInvite.query
|
||||
|
||||
if guild_id:
|
||||
query = query.filter_by(guild_id=str(guild_id))
|
||||
|
||||
if not include_revoked:
|
||||
query = query.filter_by(revoked=False)
|
||||
|
||||
return query.order_by(DiscordInvite.created_at.desc()).all()
|
||||
|
||||
def begin(self) :
|
||||
token = Configuration.query.filter_by(key='discord_token').first()
|
||||
@@ -251,6 +471,7 @@ async def on_message(message: Message):
|
||||
@bot.event
|
||||
async def on_member_join(member: Member):
|
||||
await sendWelcomeMessage(bot, member)
|
||||
await assignAutoRole(bot, member)
|
||||
|
||||
@bot.event
|
||||
async def on_member_remove(member: Member):
|
||||
@@ -259,8 +480,20 @@ async def on_member_remove(member: Member):
|
||||
@bot.event
|
||||
async def on_invite_create(invite):
|
||||
await updateInviteCache(invite.guild)
|
||||
# Synchroniser la nouvelle invitation avec la BDD
|
||||
await bot.syncInvites(invite.guild.id)
|
||||
|
||||
@bot.event
|
||||
async def on_invite_delete(invite):
|
||||
await updateInviteCache(invite.guild)
|
||||
# Marquer l'invitation comme révoquée dans la BDD
|
||||
try:
|
||||
db_invite = DiscordInvite.query.filter_by(code=invite.code).first()
|
||||
if db_invite:
|
||||
db_invite.revoked = True
|
||||
db_invite.last_sync = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
logging.info(f'Invitation {invite.code} marquée comme révoquée')
|
||||
except Exception as e:
|
||||
logging.error(f'Erreur lors de la révocation de l\'invitation {invite.code}: {e}')
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import discord
|
||||
import logging
|
||||
from database.helpers import ConfigurationHelper
|
||||
from discord import Member
|
||||
|
||||
async def assignAutoRole(bot: discord.Client, member: Member):
|
||||
"""Attribue automatiquement un rôle aux nouveaux membres"""
|
||||
config = ConfigurationHelper()
|
||||
|
||||
if not config.getValue('autorole_enable'):
|
||||
return
|
||||
|
||||
role_id = config.getIntValue('autorole_role_id')
|
||||
if not role_id:
|
||||
logging.warning('Auto-role activé mais aucun rôle configuré')
|
||||
return
|
||||
|
||||
# Chercher le rôle dans le serveur du membre
|
||||
role = member.guild.get_role(role_id)
|
||||
if not role:
|
||||
logging.error(f'Rôle auto-role {role_id} introuvable dans le serveur {member.guild.name}')
|
||||
return
|
||||
|
||||
try:
|
||||
await member.add_roles(role, reason='Auto-role: Attribution automatique à l\'arrivée')
|
||||
logging.info(f'Auto-role: Rôle "{role.name}" attribué à {member.name} sur {member.guild.name}')
|
||||
except discord.Forbidden:
|
||||
logging.error(f'Auto-role: Permission refusée pour attribuer le rôle "{role.name}" à {member.name}')
|
||||
except discord.HTTPException as e:
|
||||
logging.error(f'Auto-role: Erreur HTTP lors de l\'attribution du rôle : {e}')
|
||||
except Exception as e:
|
||||
logging.error(f'Auto-role: Erreur inattendue : {e}')
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import discord
|
||||
import feedparser
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from html import unescape
|
||||
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import FreeGame
|
||||
from discord import Client
|
||||
|
||||
RSS_URL = "https://feed.eikowagenknecht.com/lootscraper.xml"
|
||||
|
||||
KNOWN_SOURCES = {
|
||||
'epic': 'Epic Games',
|
||||
'steam': 'Steam',
|
||||
'gog': 'GOG',
|
||||
'amazon': 'Amazon Prime',
|
||||
'humble': 'Humble Bundle',
|
||||
'apple': 'Apple App Store',
|
||||
'google': 'Google Play',
|
||||
'itch': 'Itch.io',
|
||||
'ubisoft': 'Ubisoft',
|
||||
'indiegala': 'IndieGala'
|
||||
}
|
||||
|
||||
def _isEnabled():
|
||||
helper = ConfigurationHelper()
|
||||
return helper.getValue('freegames_enable') and helper.getIntValue('freegames_channel') != 0
|
||||
|
||||
def _parseSource(title: str) -> str:
|
||||
title_lower = title.lower()
|
||||
for key, name in KNOWN_SOURCES.items():
|
||||
if key in title_lower:
|
||||
return name
|
||||
match = re.search(r'\(([^)]+)\)', title)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return "Autre"
|
||||
|
||||
def _parseEntryContent(entry) -> dict:
|
||||
content = entry.get('content', [{}])[0].get('value', '') if entry.get('content') else ''
|
||||
summary = entry.get('summary', '')
|
||||
|
||||
image_url = None
|
||||
img_match = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', content or summary)
|
||||
if img_match:
|
||||
image_url = img_match.group(1)
|
||||
|
||||
valid_from = None
|
||||
valid_to = None
|
||||
|
||||
from_match = re.search(r'Offer valid from:</b>\s*([^<]+)', content)
|
||||
if from_match:
|
||||
try:
|
||||
date_str = from_match.group(1).strip()
|
||||
valid_from = datetime.strptime(date_str, '%Y-%m-%d %H:%M')
|
||||
except:
|
||||
pass
|
||||
|
||||
to_match = re.search(r'Offer valid to:</b>\s*([^<]+)', content)
|
||||
if to_match:
|
||||
try:
|
||||
date_str = to_match.group(1).strip()
|
||||
valid_to = datetime.strptime(date_str, '%Y-%m-%d %H:%M')
|
||||
except:
|
||||
pass
|
||||
|
||||
description = None
|
||||
desc_match = re.search(r'Description:</b>\s*([^<]+)', content)
|
||||
if desc_match:
|
||||
description = unescape(desc_match.group(1).strip())
|
||||
|
||||
price = None
|
||||
price_match = re.search(r'Recommended price[^:]*:</b>\s*([^<]+)', content)
|
||||
if price_match:
|
||||
price = price_match.group(1).strip()
|
||||
|
||||
genres = []
|
||||
for category in entry.get('tags', []):
|
||||
term = category.get('term', '')
|
||||
if term.startswith('Genre:'):
|
||||
genres.append(term.replace('Genre:', '').strip())
|
||||
|
||||
return {
|
||||
'image_url': image_url,
|
||||
'valid_from': valid_from,
|
||||
'valid_to': valid_to,
|
||||
'description': description,
|
||||
'price': price,
|
||||
'genres': genres
|
||||
}
|
||||
|
||||
def _fetchRSS() -> list:
|
||||
try:
|
||||
feed = feedparser.parse(RSS_URL)
|
||||
if feed.bozo:
|
||||
logging.warning(f"Erreur de parsing RSS: {feed.bozo_exception}")
|
||||
return feed.entries
|
||||
except Exception as e:
|
||||
logging.error(f"Échec de la récupération du flux RSS: {e}")
|
||||
return []
|
||||
|
||||
def _isSourceEnabled(source: str) -> bool:
|
||||
helper = ConfigurationHelper()
|
||||
enabled_sources = helper.getValue('freegames_sources')
|
||||
if not enabled_sources:
|
||||
return True
|
||||
enabled_list = [s.strip().lower() for s in enabled_sources.split(',')]
|
||||
return source.lower() in enabled_list or any(k in source.lower() for k in enabled_list)
|
||||
|
||||
def _getMentionText() -> str:
|
||||
helper = ConfigurationHelper()
|
||||
mention_type = helper.getValue('freegames_mention_type')
|
||||
|
||||
if mention_type == 'everyone':
|
||||
return '@everyone '
|
||||
elif mention_type == 'here':
|
||||
return '@here '
|
||||
elif mention_type == 'role':
|
||||
role_id = helper.getIntValue('freegames_mention_role')
|
||||
if role_id:
|
||||
return f'<@&{role_id}> '
|
||||
return ''
|
||||
|
||||
def _formatEmbed(game: FreeGame, parsed_data: dict) -> discord.Embed:
|
||||
clean_title = game.title
|
||||
if ' - ' in clean_title:
|
||||
clean_title = clean_title.split(' - ', 1)[1]
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"{clean_title} gratuit sur {game.source} !",
|
||||
url=game.url,
|
||||
color=discord.Color.green()
|
||||
)
|
||||
|
||||
if game.description:
|
||||
desc = game.description[:300] + '...' if len(game.description) > 300 else game.description
|
||||
embed.description = desc
|
||||
|
||||
price_line = ""
|
||||
if parsed_data.get('price'):
|
||||
price_line = f"~~{parsed_data['price']}~~ **Gratuit**"
|
||||
else:
|
||||
price_line = "**Gratuit**"
|
||||
|
||||
if game.valid_to:
|
||||
price_line += f" jusqu'au {game.valid_to.strftime('%d/%m/%Y')}"
|
||||
|
||||
embed.add_field(name="💰 Prix", value=price_line, inline=False)
|
||||
embed.add_field(name="🔗 Récupérer le jeu", value=f"[Ouvrir dans la boutique !]({game.url})", inline=False)
|
||||
|
||||
if game.image_url:
|
||||
embed.set_image(url=game.image_url)
|
||||
|
||||
embed.set_footer(text="🎁 Jeu à looter - Mamie Henriette")
|
||||
|
||||
return embed
|
||||
|
||||
def fetchAndStoreGames() -> list[FreeGame]:
|
||||
entries = _fetchRSS()
|
||||
new_games = []
|
||||
|
||||
for entry in entries:
|
||||
entry_id = entry.get('id', entry.get('link', ''))
|
||||
|
||||
existing = FreeGame.query.filter_by(entry_id=entry_id).first()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
title = entry.get('title', 'Jeu inconnu')
|
||||
source = _parseSource(title)
|
||||
url = entry.get('link', '')
|
||||
|
||||
parsed = _parseEntryContent(entry)
|
||||
|
||||
game = FreeGame(
|
||||
entry_id=entry_id,
|
||||
title=title,
|
||||
source=source,
|
||||
url=url,
|
||||
image_url=parsed.get('image_url'),
|
||||
description=parsed.get('description'),
|
||||
valid_from=parsed.get('valid_from'),
|
||||
valid_to=parsed.get('valid_to'),
|
||||
notified=False,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
db.session.add(game)
|
||||
new_games.append(game)
|
||||
|
||||
if new_games:
|
||||
db.session.commit()
|
||||
logging.info(f"{len(new_games)} nouveaux jeux gratuits ajoutés")
|
||||
|
||||
return new_games
|
||||
|
||||
def getPendingGames() -> list[FreeGame]:
|
||||
return FreeGame.query.filter_by(notified=False).order_by(FreeGame.created_at.desc()).all()
|
||||
|
||||
def getAllGames() -> list[FreeGame]:
|
||||
return FreeGame.query.order_by(FreeGame.created_at.desc()).all()
|
||||
|
||||
async def notifyGame(bot: Client, game_id: int) -> bool:
|
||||
if not _isEnabled():
|
||||
return False
|
||||
|
||||
game = FreeGame.query.get(game_id)
|
||||
if not game or game.notified:
|
||||
return False
|
||||
|
||||
helper = ConfigurationHelper()
|
||||
channel_id = helper.getIntValue('freegames_channel')
|
||||
channel = bot.get_channel(channel_id)
|
||||
|
||||
if not channel:
|
||||
logging.error(f"Canal Free Games {channel_id} introuvable")
|
||||
return False
|
||||
|
||||
try:
|
||||
parsed = _parseEntryContent({'content': [{'value': game.description or ''}]})
|
||||
embed = _formatEmbed(game, parsed)
|
||||
mention = _getMentionText()
|
||||
|
||||
await channel.send(content=mention if mention else None, embed=embed)
|
||||
|
||||
game.notified = True
|
||||
game.notified_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
|
||||
logging.info(f"Notification envoyée pour: {game.title}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Échec de l'envoi de la notification Free Games: {e}")
|
||||
return False
|
||||
|
||||
async def checkFreeGamesAndNotify(bot: Client):
|
||||
if not _isEnabled():
|
||||
logging.debug('Free Games est désactivé')
|
||||
return
|
||||
|
||||
helper = ConfigurationHelper()
|
||||
auto_notify = helper.getValue('freegames_auto_notify')
|
||||
|
||||
new_games = fetchAndStoreGames()
|
||||
|
||||
if auto_notify:
|
||||
for game in new_games:
|
||||
if _isSourceEnabled(game.source):
|
||||
await notifyGame(bot, game.id)
|
||||
|
||||
def markAsNotified(game_id: int) -> bool:
|
||||
game = FreeGame.query.get(game_id)
|
||||
if game:
|
||||
game.notified = True
|
||||
game.notified_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def resetNotification(game_id: int) -> bool:
|
||||
game = FreeGame.query.get(game_id)
|
||||
if game:
|
||||
game.notified = False
|
||||
game.notified_at = None
|
||||
db.session.commit()
|
||||
return True
|
||||
return False
|
||||
@@ -1220,7 +1220,7 @@ async def parse_target_user(message: Message, bot, parts: list):
|
||||
except (ValueError, discord.NotFound):
|
||||
return None
|
||||
|
||||
def create_inspect_embed(user, member, join_date, days_on_server, account_age, warnings, kicks, bans, invite_info):
|
||||
def create_inspect_embed(user, member, join_date, days_on_server, account_age, warnings, kicks, bans, invite_info, requester):
|
||||
embed = discord.Embed(
|
||||
title=f"🔍 Inspection de {user.name}",
|
||||
color=discord.Color.blue(),
|
||||
@@ -1228,7 +1228,7 @@ def create_inspect_embed(user, member, join_date, days_on_server, account_age, w
|
||||
)
|
||||
|
||||
embed.set_thumbnail(url=user.display_avatar.url)
|
||||
embed.add_field(name="👤 Utilisateur", value=f"**{user.name}**\n`{user.id}`", inline=True)
|
||||
embed.add_field(name="👤 Utilisateur", value=f"**{user.name}**\n```{user.id}```", inline=True)
|
||||
|
||||
if account_age is not None:
|
||||
embed.add_field(
|
||||
@@ -1281,7 +1281,7 @@ def create_inspect_embed(user, member, join_date, days_on_server, account_age, w
|
||||
else:
|
||||
embed.add_field(name="✅ Historique de modération", value="Aucun incident", inline=False)
|
||||
|
||||
embed.set_footer(text="Mamie Henriette")
|
||||
embed.set_footer(text=f"Demandé par {requester.name}")
|
||||
return embed
|
||||
|
||||
async def get_invite_info_for_user(bot, guild, user_id: int):
|
||||
@@ -1339,7 +1339,8 @@ async def handle_inspect_command(message: Message, bot):
|
||||
warnings,
|
||||
kicks,
|
||||
bans,
|
||||
invite_info
|
||||
invite_info,
|
||||
message.author
|
||||
)
|
||||
|
||||
await message.channel.send(embed=embed)
|
||||
|
||||
@@ -87,6 +87,8 @@ async def sendWelcomeMessage(bot: discord.Client, member: Member):
|
||||
except Exception as e:
|
||||
logging.error(f'Échec de la sauvegarde de l\'invitation : {e}')
|
||||
|
||||
account_age_days = (datetime.now(timezone.utc) - member.created_at).days
|
||||
|
||||
embed = discord.Embed(
|
||||
title='🎉 Nouveau membre !',
|
||||
description=welcome_message,
|
||||
@@ -100,8 +102,10 @@ async def sendWelcomeMessage(bot: discord.Client, member: Member):
|
||||
embed.set_footer(text=f'ID: {member.id}')
|
||||
|
||||
try:
|
||||
await channel.send(embed=embed)
|
||||
message = await channel.send(embed=embed)
|
||||
logging.info(f'Message de bienvenue envoyé pour {member.name}')
|
||||
if account_age_days < 7:
|
||||
await message.add_reaction('⚠️')
|
||||
except Exception as e:
|
||||
logging.error(f'Échec de l\'envoi du message de bienvenue : {e}')
|
||||
|
||||
|
||||
@@ -21,3 +21,6 @@ requests>=2.32.4
|
||||
# Nécessaire pour l'appel à ProtonDB
|
||||
algoliasearch>=4,<5
|
||||
|
||||
# Nécessaire pour le flux RSS des jeux gratuits
|
||||
feedparser>=6.0.0
|
||||
|
||||
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
|
||||
import threading
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Callable
|
||||
from datetime import datetime
|
||||
|
||||
@dataclass
|
||||
class BotStats:
|
||||
"""Statistiques des bots"""
|
||||
# Discord
|
||||
discord_connected: bool = False
|
||||
discord_guilds: int = 0
|
||||
discord_members: int = 0
|
||||
discord_channels: int = 0
|
||||
discord_bot_name: str = ""
|
||||
discord_bot_id: int = 0
|
||||
|
||||
# Twitch
|
||||
twitch_connected: bool = False
|
||||
twitch_channel: str = ""
|
||||
|
||||
# Cogs/Fonctionnalités activées
|
||||
cogs_enabled: Dict[str, bool] = field(default_factory=dict)
|
||||
|
||||
# Dernière mise à jour
|
||||
last_update: datetime = None
|
||||
|
||||
class StatsManager:
|
||||
"""Gestionnaire thread-safe des statistiques"""
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._stats = BotStats()
|
||||
cls._instance._stats_lock = threading.Lock()
|
||||
return cls._instance
|
||||
|
||||
def update_discord_stats(self, connected: bool = None, guilds: int = None,
|
||||
members: int = None, channels: int = None,
|
||||
bot_name: str = None, bot_id: int = None):
|
||||
"""Met à jour les stats Discord"""
|
||||
with self._stats_lock:
|
||||
if connected is not None:
|
||||
self._stats.discord_connected = connected
|
||||
if guilds is not None:
|
||||
self._stats.discord_guilds = guilds
|
||||
if members is not None:
|
||||
self._stats.discord_members = members
|
||||
if channels is not None:
|
||||
self._stats.discord_channels = channels
|
||||
if bot_name is not None:
|
||||
self._stats.discord_bot_name = bot_name
|
||||
if bot_id is not None:
|
||||
self._stats.discord_bot_id = bot_id
|
||||
self._stats.last_update = datetime.now()
|
||||
|
||||
def update_twitch_stats(self, connected: bool = None, channel: str = None):
|
||||
"""Met à jour les stats Twitch"""
|
||||
with self._stats_lock:
|
||||
if connected is not None:
|
||||
self._stats.twitch_connected = connected
|
||||
if channel is not None:
|
||||
self._stats.twitch_channel = channel
|
||||
self._stats.last_update = datetime.now()
|
||||
|
||||
def update_cogs(self, cogs: Dict[str, bool]):
|
||||
"""Met à jour les cogs activés"""
|
||||
with self._stats_lock:
|
||||
self._stats.cogs_enabled = cogs.copy()
|
||||
self._stats.last_update = datetime.now()
|
||||
|
||||
def get_stats(self) -> BotStats:
|
||||
"""Retourne une copie des stats"""
|
||||
with self._stats_lock:
|
||||
return BotStats(
|
||||
discord_connected=self._stats.discord_connected,
|
||||
discord_guilds=self._stats.discord_guilds,
|
||||
discord_members=self._stats.discord_members,
|
||||
discord_channels=self._stats.discord_channels,
|
||||
discord_bot_name=self._stats.discord_bot_name,
|
||||
discord_bot_id=self._stats.discord_bot_id,
|
||||
twitch_connected=self._stats.twitch_connected,
|
||||
twitch_channel=self._stats.twitch_channel,
|
||||
cogs_enabled=self._stats.cogs_enabled.copy(),
|
||||
last_update=self._stats.last_update
|
||||
)
|
||||
|
||||
|
||||
class DiscordBridge:
|
||||
"""
|
||||
Pont de communication entre Flask et le bot Discord.
|
||||
Permet d'exécuter des actions Discord depuis Flask de manière thread-safe.
|
||||
"""
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._bot = None
|
||||
cls._instance._loop = None
|
||||
return cls._instance
|
||||
|
||||
def register_bot(self, bot, loop):
|
||||
"""Enregistre le bot Discord et sa boucle événementielle"""
|
||||
with self._lock:
|
||||
self._bot = bot
|
||||
self._loop = loop
|
||||
logging.info("Bot Discord enregistré dans le bridge")
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
"""Vérifie si le bot est prêt"""
|
||||
return self._bot is not None and self._loop is not None and not self._loop.is_closed()
|
||||
|
||||
def get_text_channels(self) -> List[Dict]:
|
||||
"""Retourne la liste des canaux texte disponibles"""
|
||||
if not self.is_ready():
|
||||
return []
|
||||
|
||||
channels = []
|
||||
try:
|
||||
for guild in self._bot.guilds:
|
||||
for channel in guild.text_channels:
|
||||
channels.append({
|
||||
'id': channel.id,
|
||||
'name': channel.name,
|
||||
'guild_name': guild.name,
|
||||
'guild_id': guild.id
|
||||
})
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur lors de la récupération des canaux: {e}")
|
||||
|
||||
return channels
|
||||
|
||||
def send_message(self, channel_id: int, message: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Envoie un message dans un canal Discord.
|
||||
Retourne (succès, message d'erreur ou de confirmation)
|
||||
"""
|
||||
if not self.is_ready():
|
||||
return False, "Le bot Discord n'est pas connecté"
|
||||
|
||||
if not message or not message.strip():
|
||||
return False, "Le message ne peut pas être vide"
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._send_message_async(channel_id, message),
|
||||
self._loop
|
||||
)
|
||||
# Attendre le résultat avec timeout de 10 secondes
|
||||
return future.result(timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
return False, "Timeout lors de l'envoi du message"
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur lors de l'envoi du message: {e}")
|
||||
return False, f"Erreur: {str(e)}"
|
||||
|
||||
async def _send_message_async(self, channel_id: int, message: str) -> tuple[bool, str]:
|
||||
"""Coroutine interne pour envoyer le message"""
|
||||
try:
|
||||
channel = self._bot.get_channel(channel_id)
|
||||
if not channel:
|
||||
return False, "Canal introuvable"
|
||||
|
||||
await channel.send(message)
|
||||
return True, f"Message envoyé dans #{channel.name}"
|
||||
except Exception as e:
|
||||
return False, f"Erreur: {str(e)}"
|
||||
|
||||
def sync_invites(self, guild_id: int = None) -> dict:
|
||||
"""Synchronise les invitations Discord avec la base de données"""
|
||||
if not self.is_ready():
|
||||
return {'success': False, 'message': "Le bot Discord n'est pas connecté", 'synced': 0}
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._bot.syncInvites(guild_id),
|
||||
self._loop
|
||||
)
|
||||
result = future.result(timeout=30)
|
||||
result['success'] = True
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
return {'success': False, 'message': "Timeout lors de la synchronisation", 'synced': 0}
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur lors de la synchronisation des invitations: {e}")
|
||||
return {'success': False, 'message': f"Erreur: {str(e)}", 'synced': 0}
|
||||
|
||||
def revoke_invite(self, invite_code: str) -> dict:
|
||||
"""Révoque une invitation Discord"""
|
||||
if not self.is_ready():
|
||||
return {'success': False, 'message': "Le bot Discord n'est pas connecté"}
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._bot.revokeInvite(invite_code),
|
||||
self._loop
|
||||
)
|
||||
return future.result(timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
return {'success': False, 'message': "Timeout lors de la révocation"}
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur lors de la révocation de l'invitation: {e}")
|
||||
return {'success': False, 'message': f"Erreur: {str(e)}"}
|
||||
|
||||
def get_invites(self, guild_id: int = None, include_revoked: bool = False) -> list:
|
||||
"""Récupère les invitations depuis la base de données"""
|
||||
if not self.is_ready():
|
||||
return []
|
||||
|
||||
try:
|
||||
return self._bot.getInvites(guild_id, include_revoked)
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur lors de la récupération des invitations: {e}")
|
||||
return []
|
||||
|
||||
def get_guilds(self) -> list:
|
||||
"""Retourne la liste des guilds"""
|
||||
if not self.is_ready():
|
||||
return []
|
||||
|
||||
try:
|
||||
return self._bot.getAllGuilds()
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur lors de la récupération des guilds: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# Instances globales
|
||||
stats_manager = StatsManager()
|
||||
discord_bridge = DiscordBridge()
|
||||
|
||||
@@ -9,13 +9,16 @@ from twitchAPI.chat import Chat, ChatEvent, ChatMessage, EventData
|
||||
from database.helpers import ConfigurationHelper
|
||||
from twitchbot.live_alert import checkOnlineStreamer
|
||||
from webapp import webapp
|
||||
from shared_stats import stats_manager
|
||||
|
||||
USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT]
|
||||
|
||||
async def _onReady(ready_event: EventData):
|
||||
logging.info('Bot Twitch prêt')
|
||||
with webapp.app_context():
|
||||
await ready_event.chat.join_room(ConfigurationHelper().getValue('twitch_channel'))
|
||||
channel = ConfigurationHelper().getValue('twitch_channel')
|
||||
await ready_event.chat.join_room(channel)
|
||||
stats_manager.update_twitch_stats(connected=True, channel=channel)
|
||||
asyncio.get_event_loop().create_task(twitchBot._checkOnlineStreamers())
|
||||
|
||||
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
from flask import Flask
|
||||
import os
|
||||
|
||||
webapp = Flask(__name__)
|
||||
webapp.secret_key = os.environ.get('FLASK_SECRET_KEY', 'mamie-henriette-secret-key-change-me')
|
||||
|
||||
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation
|
||||
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, freegames, youtube_alert
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from webapp import webapp
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from discordbot import bot
|
||||
import asyncio
|
||||
|
||||
@webapp.route("/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(), roles = bot.getAllRoles(), guilds = bot.getAllGuilds())
|
||||
|
||||
@webapp.route("/configurations/update", methods=['POST'])
|
||||
def updateConfiguration():
|
||||
@@ -17,7 +18,8 @@ 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',
|
||||
'autorole_enable': 'autorole_role_id'
|
||||
}
|
||||
|
||||
staff_roles = request.form.getlist('moderation_staff_role_ids')
|
||||
@@ -40,3 +42,27 @@ def updateConfiguration():
|
||||
db.session.commit()
|
||||
return redirect(request.referrer)
|
||||
|
||||
@webapp.route("/configurations/leave-guild", methods=['POST'])
|
||||
def leaveGuild():
|
||||
guild_id = request.form.get('guild_id')
|
||||
confirm = request.form.get('confirm_leave')
|
||||
|
||||
if not guild_id or not confirm:
|
||||
flash('Veuillez sélectionner un serveur et confirmer l\'action.', 'error')
|
||||
return redirect(url_for('openConfigurations'))
|
||||
|
||||
try:
|
||||
guild_id_int = int(guild_id)
|
||||
# Exécuter la coroutine dans le loop du bot
|
||||
future = asyncio.run_coroutine_threadsafe(bot.leaveGuild(guild_id_int), bot.loop)
|
||||
result = future.result(timeout=10)
|
||||
|
||||
if result:
|
||||
flash('Le bot a quitté le serveur avec succès.', 'success')
|
||||
else:
|
||||
flash('Serveur non trouvé.', 'error')
|
||||
except Exception as e:
|
||||
flash(f'Erreur lors de la tentative de quitter le serveur : {str(e)}', 'error')
|
||||
|
||||
return redirect(url_for('openConfigurations'))
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
from flask import render_template, request, redirect, url_for, jsonify
|
||||
from webapp import webapp
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import FreeGame
|
||||
from discordbot import bot
|
||||
from discordbot.freegames import (
|
||||
fetchAndStoreGames,
|
||||
getPendingGames,
|
||||
getAllGames,
|
||||
markAsNotified,
|
||||
resetNotification,
|
||||
KNOWN_SOURCES
|
||||
)
|
||||
import asyncio
|
||||
|
||||
|
||||
@webapp.route("/freegames")
|
||||
def openFreeGames():
|
||||
"""Page principale de gestion des jeux gratuits"""
|
||||
# Rafraîchir les jeux depuis le flux RSS
|
||||
fetchAndStoreGames()
|
||||
|
||||
games = getAllGames()
|
||||
pending_games = getPendingGames()
|
||||
|
||||
return render_template(
|
||||
"freegames.html",
|
||||
configuration=ConfigurationHelper(),
|
||||
channels=bot.getAllTextChannel(),
|
||||
roles=bot.getAllRoles(),
|
||||
games=games,
|
||||
pending_count=len(pending_games),
|
||||
sources=KNOWN_SOURCES
|
||||
)
|
||||
|
||||
|
||||
@webapp.route("/freegames/config", methods=['POST'])
|
||||
def updateFreeGamesConfig():
|
||||
"""Met à jour la configuration du module Free Games"""
|
||||
helper = ConfigurationHelper()
|
||||
|
||||
# Gestion de la checkbox enable
|
||||
if request.form.get('freegames_channel') is not None:
|
||||
if request.form.get('freegames_enable') is None:
|
||||
helper.createOrUpdate('freegames_enable', False)
|
||||
else:
|
||||
helper.createOrUpdate('freegames_enable', 'on')
|
||||
|
||||
# Gestion de la checkbox auto_notify
|
||||
if request.form.get('freegames_auto_notify') is None:
|
||||
helper.createOrUpdate('freegames_auto_notify', False)
|
||||
else:
|
||||
helper.createOrUpdate('freegames_auto_notify', 'on')
|
||||
|
||||
# Canal de notification
|
||||
if request.form.get('freegames_channel'):
|
||||
helper.createOrUpdate('freegames_channel', request.form.get('freegames_channel'))
|
||||
|
||||
# Type de mention
|
||||
if request.form.get('freegames_mention_type'):
|
||||
helper.createOrUpdate('freegames_mention_type', request.form.get('freegames_mention_type'))
|
||||
|
||||
# Rôle à mentionner
|
||||
if request.form.get('freegames_mention_role'):
|
||||
helper.createOrUpdate('freegames_mention_role', request.form.get('freegames_mention_role'))
|
||||
|
||||
# Sources à suivre
|
||||
sources = request.form.getlist('freegames_sources')
|
||||
helper.createOrUpdate('freegames_sources', ','.join(sources) if sources else '')
|
||||
|
||||
db.session.commit()
|
||||
return redirect(url_for('openFreeGames'))
|
||||
|
||||
|
||||
@webapp.route("/freegames/notify/<int:game_id>", methods=['POST'])
|
||||
def notifyFreeGame(game_id):
|
||||
"""Envoie une notification pour un jeu spécifique"""
|
||||
from discordbot.freegames import notifyGame
|
||||
|
||||
try:
|
||||
# Utiliser le loop du bot Discord pour exécuter la coroutine
|
||||
if bot.loop and bot.loop.is_running():
|
||||
future = asyncio.run_coroutine_threadsafe(notifyGame(bot, game_id), bot.loop)
|
||||
result = future.result(timeout=30) # Attendre max 30 secondes
|
||||
else:
|
||||
return jsonify({'success': False, 'message': 'Le bot Discord n\'est pas connecté'}), 400
|
||||
|
||||
if result:
|
||||
return jsonify({'success': True, 'message': 'Notification envoyée'})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': 'Échec de l\'envoi'}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@webapp.route("/freegames/mark-notified/<int:game_id>", methods=['POST'])
|
||||
def markGameNotified(game_id):
|
||||
"""Marque un jeu comme notifié sans envoyer de message"""
|
||||
if markAsNotified(game_id):
|
||||
return jsonify({'success': True})
|
||||
return jsonify({'success': False}), 400
|
||||
|
||||
|
||||
@webapp.route("/freegames/reset/<int:game_id>", methods=['POST'])
|
||||
def resetGameNotification(game_id):
|
||||
"""Réinitialise le statut de notification d'un jeu"""
|
||||
if resetNotification(game_id):
|
||||
return jsonify({'success': True})
|
||||
return jsonify({'success': False}), 400
|
||||
|
||||
|
||||
@webapp.route("/freegames/refresh", methods=['POST'])
|
||||
def refreshFreeGames():
|
||||
"""Force le rafraîchissement du flux RSS"""
|
||||
new_games = fetchAndStoreGames()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'new_games': len(new_games),
|
||||
'message': f'{len(new_games)} nouveau(x) jeu(x) trouvé(s)'
|
||||
})
|
||||
|
||||
|
||||
@webapp.route("/freegames/delete/<int:game_id>", methods=['POST'])
|
||||
def deleteFreeGame(game_id):
|
||||
"""Supprime un jeu de la liste"""
|
||||
game = FreeGame.query.get(game_id)
|
||||
if game:
|
||||
db.session.delete(game)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
return jsonify({'success': False}), 404
|
||||
|
||||
+40
-2
@@ -1,6 +1,44 @@
|
||||
from flask import render_template
|
||||
from flask import render_template, request, jsonify
|
||||
from webapp import webapp
|
||||
from shared_stats import stats_manager, discord_bridge
|
||||
|
||||
@webapp.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
stats = stats_manager.get_stats()
|
||||
channels = discord_bridge.get_text_channels()
|
||||
return render_template("index.html", stats=stats, channels=channels)
|
||||
|
||||
@webapp.route("/api/channels")
|
||||
def api_channels():
|
||||
"""API pour récupérer la liste des canaux Discord"""
|
||||
channels = discord_bridge.get_text_channels()
|
||||
return jsonify(channels)
|
||||
|
||||
@webapp.route("/api/send-message", methods=["POST"])
|
||||
def api_send_message():
|
||||
"""API pour envoyer un message dans un canal Discord"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({"success": False, "error": "Données manquantes"}), 400
|
||||
|
||||
channel_id = data.get("channel_id")
|
||||
message = data.get("message")
|
||||
|
||||
if not channel_id:
|
||||
return jsonify({"success": False, "error": "Canal non spécifié"}), 400
|
||||
|
||||
if not message or not message.strip():
|
||||
return jsonify({"success": False, "error": "Message vide"}), 400
|
||||
|
||||
try:
|
||||
channel_id = int(channel_id)
|
||||
except ValueError:
|
||||
return jsonify({"success": False, "error": "ID de canal invalide"}), 400
|
||||
|
||||
success, result_message = discord_bridge.send_message(channel_id, message)
|
||||
|
||||
if success:
|
||||
return jsonify({"success": True, "message": result_message})
|
||||
else:
|
||||
return jsonify({"success": False, "error": result_message}), 400
|
||||
|
||||
+179
-4
@@ -1,18 +1,67 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from flask import render_template, request, redirect, url_for, flash, jsonify
|
||||
from webapp import webapp
|
||||
from database import db
|
||||
from database.models import ModerationEvent
|
||||
from database.models import ModerationEvent, DiscordInvite
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from collections import Counter
|
||||
from shared_stats import discord_bridge
|
||||
|
||||
def get_moderation_stats():
|
||||
"""Calcule les statistiques de modération"""
|
||||
events = ModerationEvent.query.all()
|
||||
|
||||
if not events:
|
||||
return {
|
||||
'total': 0,
|
||||
'top_moderators': [],
|
||||
'type_counts': {},
|
||||
'recent_24h': 0,
|
||||
'recent_7d': 0,
|
||||
'recent_30d': 0,
|
||||
'top_sanctioned': []
|
||||
}
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
# Comptages par modérateur
|
||||
mod_counts = Counter(e.staff_name for e in events if e.staff_name)
|
||||
top_moderators = mod_counts.most_common(5)
|
||||
|
||||
# Comptages par type
|
||||
type_counts = Counter(e.type for e in events if e.type)
|
||||
|
||||
# Événements récents
|
||||
recent_24h = sum(1 for e in events if e.created_at and (now - e.created_at) < timedelta(hours=24))
|
||||
recent_7d = sum(1 for e in events if e.created_at and (now - e.created_at) < timedelta(days=7))
|
||||
recent_30d = sum(1 for e in events if e.created_at and (now - e.created_at) < timedelta(days=30))
|
||||
|
||||
# Top des utilisateurs les plus sanctionnés
|
||||
user_counts = Counter(e.username for e in events if e.username)
|
||||
top_sanctioned = user_counts.most_common(5)
|
||||
|
||||
return {
|
||||
'total': len(events),
|
||||
'top_moderators': top_moderators,
|
||||
'type_counts': dict(type_counts),
|
||||
'recent_24h': recent_24h,
|
||||
'recent_7d': recent_7d,
|
||||
'recent_30d': recent_30d,
|
||||
'top_sanctioned': top_sanctioned
|
||||
}
|
||||
|
||||
@webapp.route("/moderation")
|
||||
def moderation():
|
||||
events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
|
||||
return render_template("moderation.html", events=events, event=None)
|
||||
stats = get_moderation_stats()
|
||||
return render_template("moderation.html", events=events, event=None, stats=stats,
|
||||
invites=[], invite_stats=None, guilds=[], show_invites=False, show_revoked=False)
|
||||
|
||||
@webapp.route("/moderation/edit/<int:event_id>")
|
||||
def open_edit_moderation_event(event_id):
|
||||
event = ModerationEvent.query.get_or_404(event_id)
|
||||
events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
|
||||
return render_template("moderation.html", events=events, event=event)
|
||||
stats = get_moderation_stats()
|
||||
return render_template("moderation.html", events=events, event=event, stats=stats)
|
||||
|
||||
@webapp.route("/moderation/update/<int:event_id>", methods=['POST'])
|
||||
def update_moderation_event(event_id):
|
||||
@@ -28,3 +77,129 @@ def delete_moderation_event(event_id):
|
||||
db.session.commit()
|
||||
return redirect(url_for('moderation'))
|
||||
|
||||
def _make_aware(dt):
|
||||
"""Convertit un datetime naive en aware (UTC)"""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
def get_invite_stats():
|
||||
"""Calcule les statistiques des invitations"""
|
||||
invites = DiscordInvite.query.filter_by(revoked=False).all()
|
||||
|
||||
if not invites:
|
||||
return {
|
||||
'total': 0,
|
||||
'total_uses': 0,
|
||||
'top_inviters': [],
|
||||
'permanent': 0,
|
||||
'temporary': 0,
|
||||
'expired': 0
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Comptages par inviteur
|
||||
inviter_uses = Counter()
|
||||
for inv in invites:
|
||||
if inv.inviter_name:
|
||||
inviter_uses[inv.inviter_name] += inv.uses or 0
|
||||
top_inviters = inviter_uses.most_common(5)
|
||||
|
||||
# Total des utilisations
|
||||
total_uses = sum(inv.uses or 0 for inv in invites)
|
||||
|
||||
# Invitations permanentes (max_age = 0) vs temporaires
|
||||
permanent = sum(1 for inv in invites if inv.max_age == 0)
|
||||
temporary = sum(1 for inv in invites if inv.max_age > 0)
|
||||
|
||||
# Invitations expirées (mais pas encore révoquées)
|
||||
expired = sum(1 for inv in invites if inv.expires_at and _make_aware(inv.expires_at) < now)
|
||||
|
||||
return {
|
||||
'total': len(invites),
|
||||
'total_uses': total_uses,
|
||||
'top_inviters': top_inviters,
|
||||
'permanent': permanent,
|
||||
'temporary': temporary,
|
||||
'expired': expired
|
||||
}
|
||||
|
||||
def is_invite_expired(invite, now):
|
||||
"""Vérifie si une invitation est expirée"""
|
||||
if not invite.expires_at:
|
||||
return False
|
||||
expires_at = _make_aware(invite.expires_at)
|
||||
return expires_at < now
|
||||
|
||||
@webapp.route("/moderation/invitations")
|
||||
def moderation_invitations():
|
||||
"""Affiche la liste des invitations Discord"""
|
||||
show_revoked = request.args.get('show_revoked', 'false') == 'true'
|
||||
|
||||
query = DiscordInvite.query
|
||||
if not show_revoked:
|
||||
query = query.filter_by(revoked=False)
|
||||
|
||||
invites = query.order_by(DiscordInvite.created_at.desc()).all()
|
||||
invite_stats = get_invite_stats()
|
||||
guilds = discord_bridge.get_guilds()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Pré-calculer le statut expiré pour chaque invitation
|
||||
for inv in invites:
|
||||
inv.is_expired = is_invite_expired(inv, now)
|
||||
|
||||
return render_template("moderation.html",
|
||||
events=[],
|
||||
event=None,
|
||||
stats=get_moderation_stats(),
|
||||
invites=invites,
|
||||
invite_stats=invite_stats,
|
||||
guilds=guilds,
|
||||
show_invites=True,
|
||||
show_revoked=show_revoked,
|
||||
now=now
|
||||
)
|
||||
|
||||
@webapp.route("/moderation/invitations/sync")
|
||||
def sync_invitations():
|
||||
"""Synchronise les invitations depuis Discord"""
|
||||
guild_id = request.args.get('guild_id', type=int)
|
||||
|
||||
result = discord_bridge.sync_invites(guild_id)
|
||||
|
||||
if result.get('success'):
|
||||
flash(f"✅ Synchronisation réussie : {result.get('synced', 0)} invitation(s) synchronisée(s)", 'success')
|
||||
else:
|
||||
flash(f"❌ Erreur : {result.get('message', 'Erreur inconnue')}", 'error')
|
||||
|
||||
return redirect(url_for('moderation_invitations'))
|
||||
|
||||
@webapp.route("/moderation/invitations/revoke/<invite_code>")
|
||||
def revoke_invitation(invite_code):
|
||||
"""Révoque une invitation Discord"""
|
||||
result = discord_bridge.revoke_invite(invite_code)
|
||||
|
||||
if result.get('success'):
|
||||
flash(f"✅ Invitation {invite_code} révoquée avec succès", 'success')
|
||||
else:
|
||||
flash(f"❌ Erreur : {result.get('message', 'Erreur inconnue')}", 'error')
|
||||
|
||||
return redirect(url_for('moderation_invitations'))
|
||||
|
||||
@webapp.route("/api/invitations/sync", methods=['POST'])
|
||||
def api_sync_invitations():
|
||||
"""API pour synchroniser les invitations"""
|
||||
guild_id = request.json.get('guild_id') if request.is_json else None
|
||||
result = discord_bridge.sync_invites(guild_id)
|
||||
return jsonify(result)
|
||||
|
||||
@webapp.route("/api/invitations/revoke/<invite_code>", methods=['POST'])
|
||||
def api_revoke_invitation(invite_code):
|
||||
"""API pour révoquer une invitation"""
|
||||
result = discord_bridge.revoke_invite(invite_code)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
/* MVP.css v1.17.2 - https://github.com/andybrewer/mvp */
|
||||
|
||||
:root {
|
||||
--active-brightness: 0.85;
|
||||
--border-radius: 5px;
|
||||
--box-shadow: 2px 2px 10px;
|
||||
--color-accent: #118bee15;
|
||||
--color-bg: #fff;
|
||||
--color-bg-secondary: #e9e9e9;
|
||||
--color-link: #118bee;
|
||||
--color-secondary: #920de9;
|
||||
--color-secondary-accent: #920de90b;
|
||||
--color-shadow: #f4f4f4;
|
||||
--color-table: #118bee;
|
||||
--color-text: #000;
|
||||
--color-text-secondary: #999;
|
||||
--color-scrollbar: #cacae8;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
--hover-brightness: 1.2;
|
||||
--justify-important: center;
|
||||
--justify-normal: left;
|
||||
--line-height: 1.5;
|
||||
--width-card: 285px;
|
||||
--width-card-medium: 460px;
|
||||
--width-card-wide: 800px;
|
||||
--width-content: 1080px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[color-mode="user"] {
|
||||
--color-accent: #0097fc4f;
|
||||
--color-bg: #333;
|
||||
--color-bg-secondary: #555;
|
||||
--color-link: #0097fc;
|
||||
--color-secondary: #e20de9;
|
||||
--color-secondary-accent: #e20de94f;
|
||||
--color-shadow: #bbbbbb20;
|
||||
--color-table: #0097fc;
|
||||
--color-text: #f7f7f7;
|
||||
--color-text-secondary: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
article aside {
|
||||
background: var(--color-secondary-accent);
|
||||
border-left: 4px solid var(--color-secondary);
|
||||
padding: 0.01rem 0.8rem;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-family);
|
||||
line-height: var(--line-height);
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
footer,
|
||||
header,
|
||||
main {
|
||||
margin: 0 auto;
|
||||
max-width: var(--width-content);
|
||||
/* padding: 3rem 1rem; */
|
||||
padding: 1rem 1rem;
|
||||
}
|
||||
|
||||
hr {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: none;
|
||||
height: 1px;
|
||||
margin: 4rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: var(--justify-important);
|
||||
}
|
||||
|
||||
section img,
|
||||
article img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
section pre {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
section aside {
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow) var(--color-shadow);
|
||||
margin: 1rem;
|
||||
padding: 1.25rem;
|
||||
width: var(--width-card);
|
||||
}
|
||||
|
||||
section aside:hover {
|
||||
box-shadow: var(--box-shadow) var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Headers */
|
||||
article header,
|
||||
div header,
|
||||
main header {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: var(--justify-important);
|
||||
}
|
||||
|
||||
header a b,
|
||||
header a em,
|
||||
header a i,
|
||||
header a strong {
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* header nav img {
|
||||
margin: 1rem 0;
|
||||
} */
|
||||
|
||||
section header {
|
||||
padding-top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Nav */
|
||||
nav {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-weight: bold;
|
||||
justify-content: space-between;
|
||||
/* margin-bottom: 7rem; */
|
||||
}
|
||||
|
||||
nav ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
nav ul li {
|
||||
display: inline-block;
|
||||
margin: 0 0.5rem;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Nav Dropdown */
|
||||
nav ul li:hover ul {
|
||||
display: block;
|
||||
}
|
||||
|
||||
nav ul li ul {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow) var(--color-shadow);
|
||||
display: none;
|
||||
height: auto;
|
||||
left: -2px;
|
||||
padding: 0.5rem 1rem;
|
||||
position: absolute;
|
||||
top: 1.7rem;
|
||||
white-space: nowrap;
|
||||
width: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
nav ul li ul::before {
|
||||
/* fill gap above to make mousing over them easier */
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -0.5rem;
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
nav ul li ul li,
|
||||
nav ul li ul li a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Nav for Mobile */
|
||||
@media (max-width: 768px) {
|
||||
nav {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
nav ul li {
|
||||
width: calc(100% - 1em);
|
||||
}
|
||||
|
||||
nav ul li ul {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
display: block;
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
code,
|
||||
samp {
|
||||
background-color: var(--color-accent);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-text);
|
||||
display: inline-block;
|
||||
margin: 0 0.1rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
details {
|
||||
margin: 1.3rem 0;
|
||||
}
|
||||
|
||||
details summary {
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: var(--line-height);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.1rem;
|
||||
}
|
||||
|
||||
ol li,
|
||||
ul li {
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.75rem 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 1rem 0;
|
||||
max-width: var(--width-card-wide);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
pre code,
|
||||
pre samp {
|
||||
display: block;
|
||||
max-width: var(--width-card-wide);
|
||||
padding: 0.5rem 2rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
sup {
|
||||
background-color: var(--color-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-bg);
|
||||
font-size: xx-small;
|
||||
font-weight: bold;
|
||||
margin: 0.2rem;
|
||||
padding: 0.2rem 0.3rem;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: var(--color-link);
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
filter: brightness(var(--hover-brightness));
|
||||
}
|
||||
|
||||
a:active {
|
||||
filter: brightness(var(--active-brightness));
|
||||
}
|
||||
|
||||
a b,
|
||||
a em,
|
||||
a i,
|
||||
a strong,
|
||||
button,
|
||||
input[type="submit"] {
|
||||
border-radius: var(--border-radius);
|
||||
display: inline-block;
|
||||
font-size: medium;
|
||||
font-weight: bold;
|
||||
line-height: var(--line-height);
|
||||
margin: 0.5rem 0;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
button,
|
||||
input[type="submit"] {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
button:hover,
|
||||
input[type="submit"]:hover {
|
||||
cursor: pointer;
|
||||
filter: brightness(var(--hover-brightness));
|
||||
}
|
||||
|
||||
button:active,
|
||||
input[type="submit"]:active {
|
||||
filter: brightness(var(--active-brightness));
|
||||
}
|
||||
|
||||
a b,
|
||||
a strong,
|
||||
button,
|
||||
input[type="submit"] {
|
||||
background-color: var(--color-link);
|
||||
border: 2px solid var(--color-link);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
a em,
|
||||
a i {
|
||||
border: 2px solid var(--color-link);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-link);
|
||||
display: inline-block;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
article aside a {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
/* Images */
|
||||
figure {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
figure img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
figure figcaption {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
button:disabled,
|
||||
input:disabled {
|
||||
background: var(--color-bg-secondary);
|
||||
border-color: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button[disabled]:hover,
|
||||
input[type="submit"][disabled]:hover {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
form {
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow) var(--color-shadow);
|
||||
display: block;
|
||||
max-width: var(--width-card-wide);
|
||||
min-width: var(--width-card);
|
||||
padding: 1.5rem;
|
||||
text-align: var(--justify-normal);
|
||||
}
|
||||
|
||||
form header {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
input,
|
||||
label,
|
||||
select,
|
||||
textarea {
|
||||
display: block;
|
||||
font-size: inherit;
|
||||
max-width: var(--width-card-wide);
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
input[type="checkbox"]+label,
|
||||
input[type="radio"]+label {
|
||||
display: inline-block;
|
||||
font-weight: normal;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="email"],
|
||||
textarea {
|
||||
width: calc(100% - 1.6rem);
|
||||
}
|
||||
|
||||
input[readonly],
|
||||
textarea[readonly] {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
/* Popups */
|
||||
dialog {
|
||||
max-width: 90%;
|
||||
max-height: 85dvh;
|
||||
margin: auto;
|
||||
padding-block: 0;
|
||||
padding-inline: 20px;
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: 0.5rem;
|
||||
overscroll-behavior: contain;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
/* Hide scrollbar for Firefox */
|
||||
-ms-overflow-style: none;
|
||||
/* Hide scrollbar for IE and Edge */
|
||||
scrollbar-color: transparent transparent;
|
||||
animation: bottom-to-top 0.25s ease-in-out forwards;
|
||||
}
|
||||
|
||||
dialog::-webkit-scrollbar {
|
||||
width: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
dialog::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
dialog::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@media (min-width: 650px) {
|
||||
dialog {
|
||||
max-width: 39rem;
|
||||
}
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@keyframes bottom-to-top {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10%);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
dialog hr {
|
||||
margin-block: 1rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
border-spacing: 0;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
table td,
|
||||
table th,
|
||||
table tr {
|
||||
padding: 0.4rem 0.8rem;
|
||||
text-align: var(--justify-important);
|
||||
}
|
||||
|
||||
table thead {
|
||||
background-color: var(--color-table);
|
||||
border-collapse: collapse;
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-bg);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
table thead tr:first-child th:first-child {
|
||||
border-top-left-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
table thead tr:first-child th:last-child {
|
||||
border-top-right-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
table thead th:first-child,
|
||||
table tr td:first-child {
|
||||
text-align: var(--justify-normal);
|
||||
}
|
||||
|
||||
table tr:nth-child(even) {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Quotes */
|
||||
blockquote {
|
||||
display: block;
|
||||
font-size: x-large;
|
||||
line-height: var(--line-height);
|
||||
margin: 1rem auto;
|
||||
max-width: var(--width-card-medium);
|
||||
padding: 1.5rem 1rem;
|
||||
text-align: var(--justify-important);
|
||||
}
|
||||
|
||||
blockquote footer {
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
font-size: small;
|
||||
line-height: var(--line-height);
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-scrollbar) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-scrollbar);
|
||||
border-radius: 10px;
|
||||
}
|
||||
+1956
-10
File diff suppressed because it is too large
Load Diff
@@ -1,240 +1,476 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Configuration de Mamie</h1>
|
||||
<p>Configurez les tokens Discord, les notifications Humble Bundle et l'API Twitch.</p>
|
||||
<div class="config-page">
|
||||
<h1>⚙️ Configuration</h1>
|
||||
<p>Paramétrez Discord, Twitch, YouTube et les fonctionnalités du bot.</p>
|
||||
|
||||
<h2>Discord</h2>
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
<fieldset>
|
||||
<legend>API Discord</legend>
|
||||
<label for="discord_token">Token Discord (caché)</label>
|
||||
<input name="discord_token" type="password" placeholder="Votre token Discord" />
|
||||
<small>Nécessite un redémarrage après modification</small>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Messages de bienvenue</legend>
|
||||
<label for="welcome_enable">
|
||||
<input type="checkbox" name="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked="checked"{% endif %}>
|
||||
Activer le message de bienvenue pour les nouveaux membres
|
||||
</label>
|
||||
|
||||
<label for="welcome_channel_id">Canal de bienvenue</label>
|
||||
<select name="welcome_channel_id">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('welcome_channel_id')==channel.id %}selected="selected"{% endif %}>
|
||||
{{channel.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="welcome_message">Message personnalisé de bienvenue</label>
|
||||
<textarea name="welcome_message" rows="3" placeholder="Bienvenue {member.mention} sur le serveur !">{{ configuration.getValue('welcome_message') }}</textarea>
|
||||
<small>
|
||||
<strong>Syntaxes disponibles :</strong><br>
|
||||
• <code>{member.mention}</code> - Mentionne l'utilisateur (@NomUtilisateur)<br>
|
||||
• <code>{member.name}</code> - Nom d'utilisateur (sans mention)<br>
|
||||
• <code>{member.display_name}</code> - Surnom sur le serveur<br>
|
||||
• <code>{member.id}</code> - ID de l'utilisateur<br>
|
||||
• <code>{server.name}</code> - Nom du serveur<br>
|
||||
• <code>{server.member_count}</code> - Nombre total de membres<br>
|
||||
• <code><#ID_DU_CHANNEL></code> - Mentionne un salon (ex: <#123456789012345678>)
|
||||
</small>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Messages de départ</legend>
|
||||
<label for="leave_enable">
|
||||
<input type="checkbox" name="leave_enable" {% if configuration.getValue('leave_enable') %}checked="checked"{% endif %}>
|
||||
Activer le message de départ quand un membre quitte le serveur
|
||||
</label>
|
||||
|
||||
<label for="leave_channel_id">Canal de départ</label>
|
||||
<select name="leave_channel_id">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('leave_channel_id')==channel.id %}selected="selected"{% endif %}>
|
||||
{{channel.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="leave_message">Message personnalisé de départ</label>
|
||||
<textarea name="leave_message" rows="3" placeholder="{member.mention} a quitté le serveur.">{{ configuration.getValue('leave_message') }}</textarea>
|
||||
<small>
|
||||
<strong>Syntaxes disponibles :</strong><br>
|
||||
• <code>{member.mention}</code> - Mentionne l'utilisateur (@NomUtilisateur)<br>
|
||||
• <code>{member.name}</code> - Nom d'utilisateur (sans mention)<br>
|
||||
• <code>{member.display_name}</code> - Surnom sur le serveur<br>
|
||||
• <code>{member.id}</code> - ID de l'utilisateur<br>
|
||||
• <code>{server.name}</code> - Nom du serveur<br>
|
||||
• <code>{server.member_count}</code> - Nombre total de membres<br>
|
||||
• <code><#ID_DU_CHANNEL></code> - Mentionne un salon (ex: <#123456789012345678>)
|
||||
</small>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Modération</legend>
|
||||
<label for="moderation_enable">
|
||||
<input type="checkbox" name="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked="checked"{% endif %}>
|
||||
Activer les commandes d'avertissement (!warn, !unwarn, !inspect)
|
||||
</label>
|
||||
|
||||
<label for="moderation_ban_enable">
|
||||
<input type="checkbox" name="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked="checked"{% endif %}>
|
||||
Activer les commandes de bannissement (!ban, !unban)
|
||||
</label>
|
||||
|
||||
<label for="moderation_kick_enable">
|
||||
<input type="checkbox" name="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked="checked"{% endif %}>
|
||||
Activer la commande d'expulsion (!kick)
|
||||
</label>
|
||||
|
||||
<label for="moderation_log_channel_id">Canal de logs de modération</label>
|
||||
<select name="moderation_log_channel_id">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('moderation_log_channel_id')==channel.id %}selected="selected"{% endif %}>
|
||||
{{channel.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>Toutes les actions de modération seront notifiées dans ce canal</small>
|
||||
|
||||
<label>Rôles Staff autorisés</label>
|
||||
{% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %}
|
||||
|
||||
{% if roles|length > 1 %}
|
||||
<div class="tabs">
|
||||
{% for guild_data in roles %}
|
||||
<button type="button" class="tab-button" onclick="openTab(event, 'guild-{{guild_data.guild_id}}')" {% if loop.first %}id="defaultOpen"{% endif %}>
|
||||
{{ guild_data.guild_name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for guild_data in roles %}
|
||||
<div id="guild-{{guild_data.guild_id}}" class="tab-content" {% if not loop.first %}style="display: none;"{% endif %}>
|
||||
<div style="max-height: 300px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; border-radius: 5px;">
|
||||
{% for role in guild_data.roles %}
|
||||
<label style="display: block; margin: 5px 0;">
|
||||
<input type="checkbox" name="moderation_staff_role_ids" value="{{role.id}}" {% if role.id|string in selected_roles %}checked="checked"{% endif %}>
|
||||
{% if role.color.value != 0 %}
|
||||
<span style="color:#{{ '%06x' % role.color.value }}">●</span>
|
||||
{% else %}
|
||||
<span>○</span>
|
||||
{% endif %}
|
||||
{{role.name}}
|
||||
</label>
|
||||
{% endfor %}
|
||||
<!-- Section Discord -->
|
||||
<div class="config-section open" data-section="discord">
|
||||
<div class="config-section-header" onclick="toggleSection(this)">
|
||||
<div class="config-section-icon">🎮</div>
|
||||
<div class="config-section-title">
|
||||
<h2>Discord</h2>
|
||||
<p>Token API, messages de bienvenue, auto-rôle et modération</p>
|
||||
</div>
|
||||
<svg class="config-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<small>Sélectionnez un ou plusieurs rôles qui peuvent utiliser les commandes de modération</small>
|
||||
|
||||
<script>
|
||||
function openTab(evt, tabName) {
|
||||
var i, tabcontent, tabbuttons;
|
||||
tabcontent = document.getElementsByClassName("tab-content");
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].style.display = "none";
|
||||
}
|
||||
tabbuttons = document.getElementsByClassName("tab-button");
|
||||
for (i = 0; i < tabbuttons.length; i++) {
|
||||
tabbuttons[i].className = tabbuttons[i].className.replace(" active", "");
|
||||
}
|
||||
document.getElementById(tabName).style.display = "block";
|
||||
evt.currentTarget.className += " active";
|
||||
}
|
||||
document.getElementById("defaultOpen")?.click();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.tabs {
|
||||
overflow: hidden;
|
||||
border-bottom: 2px solid #ccc;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tab-button {
|
||||
background-color: #f1f1f1;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 20px;
|
||||
transition: 0.3s;
|
||||
font-size: 14px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.tab-button:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
.tab-button.active {
|
||||
background-color: #ccc;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tab-content {
|
||||
animation: fadeEffect 0.3s;
|
||||
}
|
||||
@keyframes fadeEffect {
|
||||
from {opacity: 0;}
|
||||
to {opacity: 1;}
|
||||
}
|
||||
</style>
|
||||
|
||||
<label for="moderation_embed_delete_delay">Délai de suppression des embeds (en secondes)</label>
|
||||
<input name="moderation_embed_delete_delay" type="number" value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" placeholder="0" min="0" />
|
||||
<small>Mettre 0 pour ne pas supprimer automatiquement</small>
|
||||
</fieldset>
|
||||
<div class="config-section-content">
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
|
||||
<!-- Token Discord -->
|
||||
<div class="config-card">
|
||||
<div class="config-card-header">
|
||||
<h3>🔑 Token Discord</h3>
|
||||
<span class="badge">Sensible</span>
|
||||
</div>
|
||||
<label for="discord_token">Token du bot (caché)</label>
|
||||
<div class="token-input-wrapper">
|
||||
<input name="discord_token" id="discord_token" type="password" placeholder="Votre token Discord" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" onclick="toggleTokenVisibility('discord_token', this)" title="Afficher/Masquer">👁️</button>
|
||||
</div>
|
||||
<small>⚠️ Nécessite un redémarrage du bot après modification</small>
|
||||
</div>
|
||||
|
||||
<input type="Submit" value="Enregistrer la configuration Discord">
|
||||
</form>
|
||||
<!-- Messages de bienvenue -->
|
||||
<div class="config-card">
|
||||
<div class="config-card-header">
|
||||
<h3>👋 Messages de bienvenue</h3>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label for="welcome_enable">Activer le message de bienvenue pour les nouveaux membres</label>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" name="welcome_enable" id="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="welcome_channel_id">Canal de bienvenue</label>
|
||||
<select name="welcome_channel_id" id="welcome_channel_id">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('welcome_channel_id')==channel.id %}selected{% endif %}>
|
||||
#{{channel.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="welcome_message">Message personnalisé</label>
|
||||
<textarea name="welcome_message" id="welcome_message" rows="3" placeholder="Bienvenue {member.mention} sur le serveur !">{{ configuration.getValue('welcome_message') }}</textarea>
|
||||
|
||||
<details class="syntax-helper">
|
||||
<summary>📝 Variables disponibles</summary>
|
||||
<div class="syntax-list">
|
||||
<div class="syntax-item">
|
||||
<code>{member.mention}</code>
|
||||
<span>Mentionne l'utilisateur (@NomUtilisateur)</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{member.name}</code>
|
||||
<span>Nom d'utilisateur (sans mention)</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{member.display_name}</code>
|
||||
<span>Surnom sur le serveur</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{member.id}</code>
|
||||
<span>ID de l'utilisateur</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{server.name}</code>
|
||||
<span>Nom du serveur</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{server.member_count}</code>
|
||||
<span>Nombre total de membres</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code><#ID_DU_CHANNEL></code>
|
||||
<span>Mentionne un salon</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<h2>API Twitch</h2>
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
<label for="twitch_client_id">Client ID</label>
|
||||
<input name="twitch_client_id" type="text" value="{{ configuration.getValue('twitch_client_id') }}" />
|
||||
<label for="twitch_client_secret">Client Secret</label>
|
||||
<input name="twitch_client_secret" type="text" value="{{ configuration.getValue('twitch_client_secret') }}" />
|
||||
<label for="twitch_channel">Chaîne à rejoindre</label>
|
||||
<input name="twitch_channel" type="text" value="{{ configuration.getValue('twitch_channel') }}"
|
||||
placeholder="#machinTruc" />
|
||||
<input type="Submit" value="Enregistrer la configuration Twitch">
|
||||
<p>
|
||||
<a href="{{ url_for('twitchConfigurationHelp') }}">Aide</a>
|
||||
</p>
|
||||
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
|
||||
<p>
|
||||
<a href="{{ url_for('twitchRequestToken') }}">Obtenir token et refresh token</a>
|
||||
</p>
|
||||
<label for="twitch_access_token">Access Token</label>
|
||||
<input name="twitch_access_token" type="text" value="{{ configuration.getValue('twitch_access_token') }}"
|
||||
readonly="readonly" />
|
||||
<label for="twitch_refresh_token">Refresh Token</label>
|
||||
<input name="twitch_refresh_token" type="text" value="{{ configuration.getValue('twitch_refresh_token') }}"
|
||||
readonly="readonly" />
|
||||
<p>Nécessite un redémarrage après l'obtention des Tokens.</p>
|
||||
{% endif %}
|
||||
</form>
|
||||
<!-- Auto-Role -->
|
||||
<div class="config-card">
|
||||
<div class="config-card-header">
|
||||
<h3>🎭 Auto-Rôle</h3>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label for="autorole_enable">Attribuer automatiquement un rôle aux nouveaux membres</label>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" name="autorole_enable" id="autorole_enable" {% if configuration.getValue('autorole_enable') %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>Rôle à attribuer</label>
|
||||
{% if roles|length > 1 %}
|
||||
<div class="guild-tabs">
|
||||
{% for guild_data in roles %}
|
||||
<button type="button" class="guild-tab {% if loop.first %}active{% endif %}" onclick="switchGuildTab(this, 'autorole-guild-{{guild_data.guild_id}}')">
|
||||
{{ guild_data.guild_name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for guild_data in roles %}
|
||||
<div id="autorole-guild-{{guild_data.guild_id}}" class="guild-content {% if loop.first %}active{% endif %}">
|
||||
<select name="autorole_role_id">
|
||||
<option value="">-- Aucun rôle --</option>
|
||||
{% for role in guild_data.roles %}
|
||||
<option value="{{role.id}}" {% if configuration.getIntValue('autorole_role_id')==role.id %}selected{% endif %}>
|
||||
{% if role.color.value != 0 %}⬤{% else %}○{% endif %} {{role.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="warning-box">
|
||||
<span class="warning-box-icon">⚠️</span>
|
||||
<div class="warning-box-content">
|
||||
<strong>Permissions requises</strong>
|
||||
<p>Le bot doit avoir "Gérer les rôles" et son rôle doit être plus haut dans la hiérarchie que le rôle à attribuer.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Humble Bundle</h2>
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
<p>Humble Bundle propose régulièrement des bundles de jeux vidéo à des prix réduits. Activez les notifications pour recevoir automatiquement les nouveaux packs disponibles sur votre serveur Discord.</p>
|
||||
<!-- Messages de départ -->
|
||||
<div class="config-card">
|
||||
<div class="config-card-header">
|
||||
<h3>🚪 Messages de départ</h3>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label for="leave_enable">Activer le message quand un membre quitte le serveur</label>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" name="leave_enable" id="leave_enable" {% if configuration.getValue('leave_enable') %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="leave_channel_id">Canal de départ</label>
|
||||
<select name="leave_channel_id" id="leave_channel_id">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('leave_channel_id')==channel.id %}selected{% endif %}>
|
||||
#{{channel.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="leave_message">Message personnalisé</label>
|
||||
<textarea name="leave_message" id="leave_message" rows="3" placeholder="{member.name} a quitté le serveur.">{{ configuration.getValue('leave_message') }}</textarea>
|
||||
|
||||
<details class="syntax-helper">
|
||||
<summary>📝 Variables disponibles</summary>
|
||||
<div class="syntax-list">
|
||||
<div class="syntax-item">
|
||||
<code>{member.mention}</code>
|
||||
<span>Mentionne l'utilisateur</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{member.name}</code>
|
||||
<span>Nom d'utilisateur</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{member.display_name}</code>
|
||||
<span>Surnom sur le serveur</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{server.name}</code>
|
||||
<span>Nom du serveur</span>
|
||||
</div>
|
||||
<div class="syntax-item">
|
||||
<code>{server.member_count}</code>
|
||||
<span>Nombre de membres restants</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<label for="humble_bundle_enable">
|
||||
<input type="checkbox" name="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked="checked"{% endif %}>
|
||||
Activer les notifications Humble Bundle
|
||||
</label>
|
||||
<!-- Modération -->
|
||||
<div class="config-card">
|
||||
<div class="config-card-header">
|
||||
<h3>🛡️ Modération</h3>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label for="moderation_enable">Activer les commandes d'avertissement (!warn, !unwarn, !inspect)</label>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" name="moderation_enable" id="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label for="moderation_ban_enable">Activer les commandes de bannissement (!ban, !unban)</label>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" name="moderation_ban_enable" id="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label for="moderation_kick_enable">Activer la commande d'expulsion (!kick)</label>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" name="moderation_kick_enable" id="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="moderation_log_channel_id">Canal de logs de modération</label>
|
||||
<select name="moderation_log_channel_id" id="moderation_log_channel_id">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('moderation_log_channel_id')==channel.id %}selected{% endif %}>
|
||||
#{{channel.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>Toutes les actions de modération seront notifiées dans ce canal</small>
|
||||
|
||||
<label>Rôles Staff autorisés</label>
|
||||
{% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %}
|
||||
|
||||
{% if roles|length > 1 %}
|
||||
<div class="guild-tabs">
|
||||
{% for guild_data in roles %}
|
||||
<button type="button" class="guild-tab {% if loop.first %}active{% endif %}" onclick="switchGuildTab(this, 'moderation-guild-{{guild_data.guild_id}}')">
|
||||
{{ guild_data.guild_name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for guild_data in roles %}
|
||||
<div id="moderation-guild-{{guild_data.guild_id}}" class="guild-content {% if loop.first %}active{% endif %}">
|
||||
<div class="roles-container">
|
||||
{% for role in guild_data.roles %}
|
||||
<div class="role-item">
|
||||
<input type="checkbox" name="moderation_staff_role_ids" id="role-{{role.id}}" value="{{role.id}}" {% if role.id|string in selected_roles %}checked{% endif %}>
|
||||
<label for="role-{{role.id}}">
|
||||
<span class="role-color" style="background-color: {% if role.color.value != 0 %}#{{ '%06x' % role.color.value }}{% else %}var(--text-muted){% endif %};"></span>
|
||||
{{role.name}}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<small>Sélectionnez les rôles qui peuvent utiliser les commandes de modération</small>
|
||||
|
||||
<label for="moderation_embed_delete_delay">Délai de suppression des embeds (secondes)</label>
|
||||
<input name="moderation_embed_delete_delay" id="moderation_embed_delete_delay" type="number" value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" placeholder="0" min="0" />
|
||||
<small>Mettre 0 pour ne jamais supprimer automatiquement</small>
|
||||
</div>
|
||||
|
||||
<label for="humble_bundle_channel">Canal de notification</label>
|
||||
<select name="humble_bundle_channel">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('humble_bundle_channel')==channel.id %}selected="selected"{% endif %}>
|
||||
{{channel.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="config-actions">
|
||||
<input type="submit" value="💾 Enregistrer Discord">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="Submit" value="Enregistrer la configuration Humble Bundle">
|
||||
</form>
|
||||
{% endblock %}
|
||||
<!-- Section Twitch -->
|
||||
<div class="config-section" data-section="twitch">
|
||||
<div class="config-section-header" onclick="toggleSection(this)">
|
||||
<div class="config-section-icon">📺</div>
|
||||
<div class="config-section-title">
|
||||
<h2>Twitch</h2>
|
||||
<p>Configuration de l'API Twitch pour les alertes live</p>
|
||||
</div>
|
||||
<svg class="config-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="config-section-content">
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
<label for="twitch_client_id">Client ID</label>
|
||||
<input name="twitch_client_id" id="twitch_client_id" type="text" value="{{ configuration.getValue('twitch_client_id') }}" placeholder="Votre Client ID Twitch" />
|
||||
|
||||
<label for="twitch_client_secret">Client Secret</label>
|
||||
<div class="token-input-wrapper">
|
||||
<input name="twitch_client_secret" id="twitch_client_secret" type="password" value="{{ configuration.getValue('twitch_client_secret') }}" placeholder="Votre Client Secret" />
|
||||
<button type="button" class="token-toggle" onclick="toggleTokenVisibility('twitch_client_secret', this)" title="Afficher/Masquer">👁️</button>
|
||||
</div>
|
||||
|
||||
<label for="twitch_channel">Chaîne à rejoindre</label>
|
||||
<input name="twitch_channel" id="twitch_channel" type="text" value="{{ configuration.getValue('twitch_channel') }}" placeholder="#votre_chaine" />
|
||||
|
||||
<a href="{{ url_for('twitchConfigurationHelp') }}" class="help-link">
|
||||
❓ Besoin d'aide pour configurer Twitch ?
|
||||
</a>
|
||||
|
||||
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
|
||||
<div class="config-card" style="margin-top: var(--space-lg);">
|
||||
<div class="config-card-header">
|
||||
<h3>🔐 Tokens d'accès</h3>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('twitchRequestToken') }}" class="btn" style="margin-bottom: var(--space-md); display: inline-flex;">
|
||||
🔄 Obtenir/Rafraîchir les tokens
|
||||
</a>
|
||||
|
||||
<label for="twitch_access_token">Access Token</label>
|
||||
<input name="twitch_access_token" id="twitch_access_token" type="text" value="{{ configuration.getValue('twitch_access_token') }}" readonly />
|
||||
|
||||
<label for="twitch_refresh_token">Refresh Token</label>
|
||||
<input name="twitch_refresh_token" id="twitch_refresh_token" type="text" value="{{ configuration.getValue('twitch_refresh_token') }}" readonly />
|
||||
|
||||
<small>⚠️ Nécessite un redémarrage après l'obtention des tokens</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="config-actions">
|
||||
<input type="submit" value="💾 Enregistrer Twitch">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Humble Bundle -->
|
||||
<div class="config-section" data-section="humble">
|
||||
<div class="config-section-header" onclick="toggleSection(this)">
|
||||
<div class="config-section-icon">🎁</div>
|
||||
<div class="config-section-title">
|
||||
<h2>Humble Bundle</h2>
|
||||
<p>Notifications automatiques des nouveaux bundles de jeux</p>
|
||||
</div>
|
||||
<svg class="config-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="config-section-content">
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
<p style="color: var(--text-secondary); margin-bottom: var(--space-lg);">
|
||||
Humble Bundle propose régulièrement des bundles de jeux vidéo à prix réduits. Activez les notifications pour recevoir automatiquement les nouveaux packs sur votre serveur Discord.
|
||||
</p>
|
||||
|
||||
<div class="toggle-row">
|
||||
<label for="humble_bundle_enable">Activer les notifications Humble Bundle</label>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" name="humble_bundle_enable" id="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="humble_bundle_channel">Canal de notification</label>
|
||||
<select name="humble_bundle_channel" id="humble_bundle_channel">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('humble_bundle_channel')==channel.id %}selected{% endif %}>
|
||||
#{{channel.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<div class="config-actions">
|
||||
<input type="submit" value="💾 Enregistrer Humble Bundle">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Zone Danger -->
|
||||
<div class="config-section danger" data-section="danger">
|
||||
<div class="config-section-header" onclick="toggleSection(this)">
|
||||
<div class="config-section-icon">⚠️</div>
|
||||
<div class="config-section-title">
|
||||
<h2>Zone Danger</h2>
|
||||
<p>Actions irréversibles - Utilisez avec précaution</p>
|
||||
</div>
|
||||
<svg class="config-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="config-section-content">
|
||||
<form action="{{ url_for('leaveGuild') }}" method="POST" onsubmit="return confirmLeave();">
|
||||
<div class="danger-box">
|
||||
<span class="danger-box-icon">🚨</span>
|
||||
<div class="danger-box-content">
|
||||
<strong>Quitter un serveur Discord</strong>
|
||||
<p>Cette action est irréversible ! Le bot quittera définitivement le serveur sélectionné. Pour rejoindre à nouveau, vous devrez ré-inviter le bot.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if guilds and guilds|length > 0 %}
|
||||
<label for="guild_id">Sélectionner un serveur à quitter</label>
|
||||
<select name="guild_id" id="guild_id" required>
|
||||
<option value="">-- Choisir un serveur --</option>
|
||||
{% for guild in guilds %}
|
||||
<option value="{{ guild.id }}">{{ guild.name }} ({{ guild.member_count }} membres)</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<div class="toggle-row" style="background: rgba(220, 38, 38, 0.1); margin-top: var(--space-md);">
|
||||
<label for="confirm_leave" style="color: var(--danger);">Je confirme vouloir faire quitter le bot de ce serveur</label>
|
||||
<div class="toggle-switch">
|
||||
<input type="checkbox" name="confirm_leave" id="confirm_leave" value="1" required>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-actions">
|
||||
<input type="submit" value="🚪 Quitter le serveur" class="btn-danger" style="flex: none;">
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color: var(--text-muted); text-align: center; padding: var(--space-lg);">
|
||||
Aucun serveur Discord connecté ou le bot n'est pas encore démarré.
|
||||
</p>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Toggle section open/close
|
||||
function toggleSection(header) {
|
||||
const section = header.closest('.config-section');
|
||||
section.classList.toggle('open');
|
||||
}
|
||||
|
||||
// Toggle token visibility
|
||||
function toggleTokenVisibility(inputId, button) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
button.textContent = '🙈';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
button.textContent = '👁️';
|
||||
}
|
||||
}
|
||||
|
||||
// Switch guild tabs
|
||||
function switchGuildTab(button, contentId) {
|
||||
// Get parent tabs container
|
||||
const tabsContainer = button.closest('.guild-tabs');
|
||||
const card = button.closest('.config-card');
|
||||
|
||||
// Deactivate all tabs in this group
|
||||
tabsContainer.querySelectorAll('.guild-tab').forEach(tab => tab.classList.remove('active'));
|
||||
button.classList.add('active');
|
||||
|
||||
// Hide all content in this card
|
||||
card.querySelectorAll('.guild-content').forEach(content => content.classList.remove('active'));
|
||||
document.getElementById(contentId).classList.add('active');
|
||||
}
|
||||
|
||||
// Confirm leave guild
|
||||
function confirmLeave() {
|
||||
const select = document.getElementById('guild_id');
|
||||
if (!select.value) {
|
||||
alert('Veuillez sélectionner un serveur.');
|
||||
return false;
|
||||
}
|
||||
const serverName = select.options[select.selectedIndex].text;
|
||||
return confirm('Êtes-vous VRAIMENT sûr de vouloir quitter le serveur "' + serverName + '" ?\n\nCette action est IRRÉVERSIBLE !');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>🎮 Jeux Gratuits</h1>
|
||||
<p>Gérez les notifications de jeux gratuits via le flux RSS LootScraper. Activez le module, choisissez les sources à suivre et décidez quels jeux partager avec votre communauté.</p>
|
||||
|
||||
<h2>Configuration</h2>
|
||||
<form action="{{ url_for('updateFreeGamesConfig') }}" method="POST">
|
||||
<fieldset>
|
||||
<legend>Paramètres généraux</legend>
|
||||
|
||||
<label for="freegames_enable">
|
||||
<input type="checkbox" name="freegames_enable" id="freegames_enable" {% if configuration.getValue('freegames_enable') %}checked="checked"{% endif %}>
|
||||
Activer le module Jeux Gratuits
|
||||
</label>
|
||||
|
||||
<label for="freegames_auto_notify">
|
||||
<input type="checkbox" name="freegames_auto_notify" id="freegames_auto_notify" {% if configuration.getValue('freegames_auto_notify') %}checked="checked"{% endif %}>
|
||||
Notification automatique des nouveaux jeux
|
||||
</label>
|
||||
<small>Si activé, les nouveaux jeux des sources sélectionnées seront automatiquement partagés</small>
|
||||
|
||||
<label for="freegames_channel">Canal de notification</label>
|
||||
<select name="freegames_channel" id="freegames_channel">
|
||||
<option value="">-- Sélectionner un canal --</option>
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}" {% if configuration.getIntValue('freegames_channel')==channel.id %}selected="selected"{% endif %}>
|
||||
{{channel.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Mentions</legend>
|
||||
|
||||
<label for="freegames_mention_type">Type de mention</label>
|
||||
<select name="freegames_mention_type" id="freegames_mention_type" onchange="toggleRoleSelect()">
|
||||
<option value="none" {% if configuration.getValue('freegames_mention_type') == 'none' or not configuration.getValue('freegames_mention_type') %}selected{% endif %}>Aucune mention</option>
|
||||
<option value="here" {% if configuration.getValue('freegames_mention_type') == 'here' %}selected{% endif %}>@here</option>
|
||||
<option value="everyone" {% if configuration.getValue('freegames_mention_type') == 'everyone' %}selected{% endif %}>@everyone</option>
|
||||
<option value="role" {% if configuration.getValue('freegames_mention_type') == 'role' %}selected{% endif %}>Rôle spécifique</option>
|
||||
</select>
|
||||
|
||||
<div id="role-select-container" style="{% if configuration.getValue('freegames_mention_type') != 'role' %}display: none;{% endif %}">
|
||||
<label for="freegames_mention_role">Rôle à mentionner</label>
|
||||
{% for guild_data in roles %}
|
||||
<select name="freegames_mention_role" id="freegames_mention_role">
|
||||
<option value="">-- Sélectionner un rôle --</option>
|
||||
{% for role in guild_data.roles %}
|
||||
<option value="{{role.id}}" {% if configuration.getIntValue('freegames_mention_role')==role.id %}selected="selected"{% endif %}>
|
||||
{% if role.color.value != 0 %}●{% else %}○{% endif %} {{role.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Sources à suivre</legend>
|
||||
<small>Sélectionnez les plateformes dont vous souhaitez recevoir les offres. Si aucune n'est sélectionnée, toutes les sources seront incluses.</small>
|
||||
|
||||
{% set enabled_sources = (configuration.getValue('freegames_sources') or '').split(',') %}
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; margin-top: 15px;">
|
||||
{% for key, name in sources.items() %}
|
||||
<label style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" name="freegames_sources" value="{{key}}" {% if key in enabled_sources or not configuration.getValue('freegames_sources') %}checked{% endif %}>
|
||||
{{name}}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<input type="submit" value="💾 Enregistrer la configuration">
|
||||
</form>
|
||||
|
||||
<h2>Jeux disponibles <span class="badge">{{pending_count}} en attente</span></h2>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<button type="button" onclick="refreshGames()" class="btn-secondary">🔄 Rafraîchir le flux RSS</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label for="filter-source">Filtrer par source :</label>
|
||||
<select id="filter-source" onchange="filterGames()">
|
||||
<option value="all">Toutes les sources</option>
|
||||
{% for key, name in sources.items() %}
|
||||
<option value="{{name}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="filter-status" style="margin-left: 20px;">Filtrer par statut :</label>
|
||||
<select id="filter-status" onchange="filterGames()">
|
||||
<option value="all">Tous</option>
|
||||
<option value="pending">En attente</option>
|
||||
<option value="notified">Notifiés</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="games-list">
|
||||
{% for game in games %}
|
||||
<div class="game-card" data-source="{{game.source}}" data-status="{% if game.notified %}notified{% else %}pending{% endif %}">
|
||||
<div class="game-header">
|
||||
{% if game.image_url %}
|
||||
<img src="{{game.image_url}}" alt="{{game.title}}" class="game-thumb">
|
||||
{% else %}
|
||||
<div class="game-thumb-placeholder">🎮</div>
|
||||
{% endif %}
|
||||
<div class="game-info">
|
||||
<h3>{{game.title}}</h3>
|
||||
<span class="source-badge">{{game.source}}</span>
|
||||
{% if game.notified %}
|
||||
<span class="status-badge notified">✓ Notifié</span>
|
||||
{% else %}
|
||||
<span class="status-badge pending">⏳ En attente</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if game.description %}
|
||||
<p class="game-description">{{game.description[:150]}}{% if game.description|length > 150 %}...{% endif %}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="game-meta">
|
||||
{% if game.valid_to %}
|
||||
<span title="Date de fin">⏰ Jusqu'au {{game.valid_to.strftime('%d/%m/%Y')}}</span>
|
||||
{% endif %}
|
||||
<a href="{{game.url}}" target="_blank" rel="noopener">🔗 Voir l'offre</a>
|
||||
</div>
|
||||
|
||||
<div class="game-actions">
|
||||
{% if not game.notified %}
|
||||
<button type="button" onclick="notifyGame({{game.id}}, this)" class="btn-primary">📢 Notifier</button>
|
||||
<button type="button" onclick="markNotified({{game.id}}, this)" class="btn-secondary">✓ Marquer comme notifié</button>
|
||||
{% else %}
|
||||
<button type="button" onclick="resetGame({{game.id}}, this)" class="btn-secondary">↩️ Réinitialiser</button>
|
||||
{% endif %}
|
||||
<button type="button" onclick="deleteGame({{game.id}}, this)" class="btn-danger">🗑️ Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>Aucun jeu gratuit trouvé. Cliquez sur "Rafraîchir le flux RSS" pour récupérer les dernières offres.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
background: #5865F2;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.games-list {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
background: var(--color-bg-secondary, #f5f5f5);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
}
|
||||
|
||||
.game-card[data-status="notified"] {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.game-header {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.game-thumb {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.game-thumb-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--color-bg-tertiary, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.game-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.game-info h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background: #FF9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.notified {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.game-description {
|
||||
color: var(--color-text-secondary, #666);
|
||||
font-size: 0.9em;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.game-meta {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-secondary, #666);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.game-meta a {
|
||||
color: #5865F2;
|
||||
}
|
||||
|
||||
.game-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.game-actions button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.game-actions button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #5865F2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #747F8D;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ED4245;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function toggleRoleSelect() {
|
||||
const mentionType = document.getElementById('freegames_mention_type').value;
|
||||
const roleContainer = document.getElementById('role-select-container');
|
||||
roleContainer.style.display = mentionType === 'role' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function filterGames() {
|
||||
const sourceFilter = document.getElementById('filter-source').value;
|
||||
const statusFilter = document.getElementById('filter-status').value;
|
||||
const cards = document.querySelectorAll('.game-card');
|
||||
|
||||
cards.forEach(card => {
|
||||
const source = card.dataset.source;
|
||||
const status = card.dataset.status;
|
||||
|
||||
const sourceMatch = sourceFilter === 'all' || source === sourceFilter;
|
||||
const statusMatch = statusFilter === 'all' || status === statusFilter;
|
||||
|
||||
card.classList.toggle('hidden', !(sourceMatch && statusMatch));
|
||||
});
|
||||
}
|
||||
|
||||
function refreshGames() {
|
||||
fetch('/freegames/refresh', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
alert(data.message);
|
||||
if (data.new_games > 0) {
|
||||
location.reload();
|
||||
}
|
||||
})
|
||||
.catch(e => alert('Erreur: ' + e));
|
||||
}
|
||||
|
||||
function notifyGame(id, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Envoi...';
|
||||
|
||||
fetch('/freegames/notify/' + id, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Erreur: ' + data.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📢 Notifier';
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
alert('Erreur: ' + e);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📢 Notifier';
|
||||
});
|
||||
}
|
||||
|
||||
function markNotified(id, btn) {
|
||||
fetch('/freegames/mark-notified/' + id, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
})
|
||||
.catch(e => alert('Erreur: ' + e));
|
||||
}
|
||||
|
||||
function resetGame(id, btn) {
|
||||
fetch('/freegames/reset/' + id, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
})
|
||||
.catch(e => alert('Erreur: ' + e));
|
||||
}
|
||||
|
||||
function deleteGame(id, btn) {
|
||||
if (!confirm('Supprimer ce jeu de la liste ?')) return;
|
||||
|
||||
fetch('/freegames/delete/' + id, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
btn.closest('.game-card').remove();
|
||||
}
|
||||
})
|
||||
.catch(e => alert('Erreur: ' + e));
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
{% block content %}
|
||||
<h1>Humeurs de Mamie</h1>
|
||||
<p>Définissez les statuts Discord qui changeront automatiquement toutes les 10 minutes pour donner de la personnalité à votre bot.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<h3>📝 Variables disponibles</h3>
|
||||
<p>Vous pouvez utiliser ces variables dans vos humeurs, elles seront remplacées automatiquement :</p>
|
||||
<ul>
|
||||
<li><code>{servers}</code> — Nombre de serveurs gérés par le bot</li>
|
||||
<li><code>{members}</code> — Nombre total de membres sur tous les serveurs</li>
|
||||
<li><code>{channels}</code> — Nombre total de salons sur tous les serveurs</li>
|
||||
</ul>
|
||||
<p><em>Exemple : "Je surveille {members} personnes sur {servers} serveurs 👀"</em></p>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
+158
-4
@@ -1,7 +1,161 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Bienvenue sur l'interface d'administration de Mamie.</h1>
|
||||
<p>Nous devons définir ce que nous souhaitons afficher sur la page d'accueil. Peut-être l'historique des dernières
|
||||
modifications ? de la modération ?</p>
|
||||
{% endblock %}
|
||||
<h1>Tableau de bord</h1>
|
||||
<p>Bienvenue sur l'interface d'administration de Mamie Henriette.</p>
|
||||
|
||||
<!-- Services -->
|
||||
<section class="dashboard-section">
|
||||
<h2>🔌 Services</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card {{ 'online' if stats.discord_connected else 'offline' }}">
|
||||
<div class="stat-icon">🤖</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Discord</span>
|
||||
<span class="stat-value">{{ 'Connecté' if stats.discord_connected else 'Déconnecté' }}</span>
|
||||
{% if stats.discord_connected and stats.discord_bot_name %}
|
||||
<span class="stat-sub">{{ stats.discord_bot_name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stat-status {{ 'online' if stats.discord_connected else 'offline' }}"></div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card {{ 'online' if stats.twitch_connected else 'offline' }}">
|
||||
<div class="stat-icon">📺</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Twitch</span>
|
||||
<span class="stat-value">{{ 'Connecté' if stats.twitch_connected else 'Déconnecté' }}</span>
|
||||
{% if stats.twitch_connected and stats.twitch_channel %}
|
||||
<span class="stat-sub">#{{ stats.twitch_channel }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stat-status {{ 'online' if stats.twitch_connected else 'offline' }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Statistiques Discord -->
|
||||
{% if stats.discord_connected %}
|
||||
<section class="dashboard-section">
|
||||
<h2>📊 Statistiques Discord</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🏠</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Serveurs</span>
|
||||
<span class="stat-value big">{{ stats.discord_guilds }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">👥</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Membres</span>
|
||||
<span class="stat-value big">{{ stats.discord_members }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">💬</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Salons</span>
|
||||
<span class="stat-value big">{{ stats.discord_channels }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Envoyer une annonce -->
|
||||
<section class="dashboard-section">
|
||||
<h2>📢 Envoyer une annonce</h2>
|
||||
<form id="announce-form" class="announce-form-inline">
|
||||
<div class="announce-row">
|
||||
<input type="text" id="message-input" name="message" placeholder="Votre message..." required>
|
||||
<select id="channel-select" name="channel_id" required>
|
||||
<option value="">Salon</option>
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.id }}">#{{ channel.name }} ({{ channel.guild_name }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" id="send-btn">
|
||||
<span class="btn-text">📨 Envoyer</span>
|
||||
<span class="btn-loading" style="display: none;">⏳</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="announce-result" class="announce-result" style="display: none;"></div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- Fonctionnalités -->
|
||||
<section class="dashboard-section">
|
||||
<h2>⚡ Fonctionnalités</h2>
|
||||
<div class="cogs-grid">
|
||||
{% for cog_name, enabled in stats.cogs_enabled.items() %}
|
||||
<div class="cog-item {{ 'enabled' if enabled else 'disabled' }}">
|
||||
<span class="cog-status">{{ '✅' if enabled else '❌' }}</span>
|
||||
<span class="cog-name">{{ cog_name }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Aucune information disponible. Le bot Discord n'est peut-être pas encore connecté.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if stats.last_update %}
|
||||
<p class="last-update">Dernière mise à jour : {{ stats.last_update.strftime('%d/%m/%Y à %H:%M:%S') }}</p>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
document.getElementById('announce-form')?.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const btn = document.getElementById('send-btn');
|
||||
const btnText = btn.querySelector('.btn-text');
|
||||
const btnLoading = btn.querySelector('.btn-loading');
|
||||
const result = document.getElementById('announce-result');
|
||||
|
||||
const channelId = document.getElementById('channel-select').value;
|
||||
const message = document.getElementById('message-input').value;
|
||||
|
||||
// UI loading
|
||||
btn.disabled = true;
|
||||
btnText.style.display = 'none';
|
||||
btnLoading.style.display = 'inline';
|
||||
result.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/send-message', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel_id: channelId,
|
||||
message: message
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
result.style.display = 'block';
|
||||
if (data.success) {
|
||||
result.className = 'announce-result success';
|
||||
result.textContent = '✅ ' + data.message;
|
||||
document.getElementById('message-input').value = '';
|
||||
} else {
|
||||
result.className = 'announce-result error';
|
||||
result.textContent = '❌ ' + data.error;
|
||||
}
|
||||
} catch (error) {
|
||||
result.style.display = 'block';
|
||||
result.className = 'announce-result error';
|
||||
result.textContent = '❌ Erreur de connexion';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btnText.style.display = 'inline';
|
||||
btnLoading.style.display = 'none';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,61 +3,175 @@
|
||||
{% block content %}
|
||||
<h1>Modération Discord</h1>
|
||||
|
||||
<p>
|
||||
Historique des actions de modération effectuées sur le serveur Discord.
|
||||
<p>Historique des actions de modération effectuées sur le serveur Discord. Le bot enregistre automatiquement les avertissements, exclusions et bannissements.</p>
|
||||
|
||||
Le bot enregistre automatiquement les avertissements, exclusions et bannissements.
|
||||
<!-- Navigation entre les sections -->
|
||||
<div class="moderation-nav">
|
||||
<a href="{{ url_for('moderation') }}" class="nav-btn {{ '' if show_invites else 'active' }}">📋 Événements de modération</a>
|
||||
<a href="{{ url_for('moderation_invitations') }}" class="nav-btn {{ 'active' if show_invites else '' }}">🔗 Invitations</a>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Commande</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>!averto @utilisateur raison</strong><br><small>Alias : !warn, !av, !avertissement</small></td>
|
||||
<td>Avertit un utilisateur et enregistre l'avertissement dans la base de données</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>!delaverto id</strong><br><small>Alias : !removewarn, !delwarn</small></td>
|
||||
<td>Retire un avertissement en utilisant son numéro d'ID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>!warnings</strong> ou <strong>!warnings @utilisateur</strong><br><small>Alias : !listevent, !listwarn</small></td>
|
||||
<td>Affiche la liste des événements de modération (tous ou pour un utilisateur spécifique)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>!inspect @utilisateur</strong> ou <strong>!inspect id</strong></td>
|
||||
<td>Affiche des informations détaillées sur un utilisateur : création du compte, date d'arrivée, historique de modération</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>!kick @utilisateur raison</strong></td>
|
||||
<td>Expulse un utilisateur du serveur</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>!ban @utilisateur raison</strong></td>
|
||||
<td>Bannit définitivement un utilisateur du serveur</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>!unban discord_id</strong> ou <strong>!unban #sanction_id raison</strong></td>
|
||||
<td>Révoque le bannissement d'un utilisateur et lui envoie une invitation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>!banlist</strong></td>
|
||||
<td>Affiche la liste des utilisateurs actuellement bannis du serveur</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>!aide</strong><br><small>Alias : !help</small></td>
|
||||
<td>Affiche l'aide avec toutes les commandes disponibles</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</p>
|
||||
<!-- Section Statistiques -->
|
||||
<div class="dashboard-section">
|
||||
<h2>📊 Statistiques</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📋</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Total des sanctions</span>
|
||||
<span class="stat-value big">{{ stats.total }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⏰</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Dernières 24h</span>
|
||||
<span class="stat-value big">{{ stats.recent_24h }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📅</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">7 derniers jours</span>
|
||||
<span class="stat-value big">{{ stats.recent_7d }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📆</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">30 derniers jours</span>
|
||||
<span class="stat-value big">{{ stats.recent_30d }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Types de sanctions et Top modérateurs -->
|
||||
<div class="moderation-insights">
|
||||
<div class="insight-card">
|
||||
<h3>🛡️ Top Modérateurs</h3>
|
||||
{% if stats.top_moderators %}
|
||||
<div class="leaderboard">
|
||||
{% for mod_name, count in stats.top_moderators %}
|
||||
<div class="leaderboard-item">
|
||||
<span class="rank">{{ loop.index }}</span>
|
||||
<span class="name">{{ mod_name }}</span>
|
||||
<span class="count">{{ count }} sanction{{ 's' if count > 1 else '' }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Aucune donnée disponible</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="insight-card">
|
||||
<h3>⚖️ Types de sanctions</h3>
|
||||
{% if stats.type_counts %}
|
||||
<div class="type-badges">
|
||||
{% for type_name, count in stats.type_counts.items() %}
|
||||
<div class="type-badge {{ type_name | lower }}">
|
||||
<span class="type-name">{{ type_name }}</span>
|
||||
<span class="type-count">{{ count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Aucune donnée disponible</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="insight-card">
|
||||
<h3>⚠️ Utilisateurs les plus sanctionnés</h3>
|
||||
{% if stats.top_sanctioned %}
|
||||
<div class="leaderboard">
|
||||
{% for username, count in stats.top_sanctioned %}
|
||||
<div class="leaderboard-item warning">
|
||||
<span class="rank">{{ loop.index }}</span>
|
||||
<span class="name">{{ username }}</span>
|
||||
<span class="count">{{ count }} sanction{{ 's' if count > 1 else '' }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Aucune donnée disponible</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Commandes (collapsible) -->
|
||||
<details class="commands-section">
|
||||
<summary>
|
||||
<span class="summary-icon">📖</span>
|
||||
<span>Commandes de modération disponibles</span>
|
||||
</summary>
|
||||
<div class="commands-grid">
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!averto @utilisateur raison</code>
|
||||
</div>
|
||||
<p>Avertit un utilisateur et enregistre l'avertissement</p>
|
||||
<small>Alias : !warn, !av, !avertissement</small>
|
||||
</div>
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!delaverto id</code>
|
||||
</div>
|
||||
<p>Retire un avertissement par son ID</p>
|
||||
<small>Alias : !removewarn, !delwarn</small>
|
||||
</div>
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!warnings [@utilisateur]</code>
|
||||
</div>
|
||||
<p>Liste les événements de modération</p>
|
||||
<small>Alias : !listevent, !listwarn</small>
|
||||
</div>
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!inspect @utilisateur</code>
|
||||
</div>
|
||||
<p>Informations détaillées sur un utilisateur</p>
|
||||
<small>Aussi : !inspect id</small>
|
||||
</div>
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!kick @utilisateur raison</code>
|
||||
</div>
|
||||
<p>Expulse un utilisateur du serveur</p>
|
||||
</div>
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!ban @utilisateur raison</code>
|
||||
</div>
|
||||
<p>Bannit définitivement un utilisateur</p>
|
||||
</div>
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!unban discord_id</code>
|
||||
</div>
|
||||
<p>Révoque un bannissement</p>
|
||||
<small>Aussi : !unban #sanction_id raison</small>
|
||||
</div>
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!banlist</code>
|
||||
</div>
|
||||
<p>Liste des utilisateurs bannis</p>
|
||||
</div>
|
||||
<div class="command-card">
|
||||
<div class="command-header">
|
||||
<code>!aide</code>
|
||||
</div>
|
||||
<p>Affiche toutes les commandes</p>
|
||||
<small>Alias : !help</small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% if not event %}
|
||||
<h2>Événements de modération</h2>
|
||||
<h2>📜 Historique des événements</h2>
|
||||
{% if events %}
|
||||
<table class="moderation">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -73,24 +187,31 @@
|
||||
<tbody>
|
||||
{% for mod_event in events %}
|
||||
<tr>
|
||||
<td>{{ mod_event.type }}</td>
|
||||
<td><span class="event-type {{ mod_event.type | lower if mod_event.type else '' }}">{{ mod_event.type }}</span></td>
|
||||
<td>{{ mod_event.username }}</td>
|
||||
<td>{{ mod_event.discord_id }}</td>
|
||||
<td><code>{{ mod_event.discord_id }}</code></td>
|
||||
<td>{{ mod_event.created_at.strftime('%d/%m/%Y %H:%M') if mod_event.created_at else 'N/A' }}</td>
|
||||
<td>{{ mod_event.reason }}</td>
|
||||
<td>{{ mod_event.staff_name }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('open_edit_moderation_event', event_id = mod_event.id) }}" class="icon">✐</a>
|
||||
<a href="{{ url_for('delete_moderation_event', event_id = mod_event.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')" class="icon">🗑</a>
|
||||
<a href="{{ url_for('open_edit_moderation_event', event_id = mod_event.id) }}" class="icon" title="Modifier">✐</a>
|
||||
<a href="{{ url_for('delete_moderation_event', event_id = mod_event.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')" class="icon" title="Supprimer">🗑</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🎉</div>
|
||||
<p>Aucun événement de modération enregistré</p>
|
||||
<small>Le serveur est sage !</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if event %}
|
||||
<h2>Editer un événement</h2>
|
||||
<h2>✏️ Editer un événement</h2>
|
||||
<form action="{{ url_for('update_moderation_event', event_id = event.id) }}" method="POST">
|
||||
<label for="type">Type</label>
|
||||
<input name="type" type="text" value="{{ event.type }}" disabled />
|
||||
@@ -107,4 +228,142 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if show_invites %}
|
||||
<!-- Section Invitations Discord -->
|
||||
<h2>🔗 Invitations Discord</h2>
|
||||
|
||||
<div class="invites-actions">
|
||||
<a href="{{ url_for('sync_invitations') }}" class="btn btn-primary">🔄 Synchroniser les invitations</a>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="show-revoked" {{ 'checked' if show_revoked else '' }} onchange="toggleRevoked()">
|
||||
Afficher les invitations révoquées
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{% if invite_stats %}
|
||||
<!-- Statistiques des invitations -->
|
||||
<div class="dashboard-section">
|
||||
<h3>📊 Statistiques des invitations</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🔗</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Invitations actives</span>
|
||||
<span class="stat-value big">{{ invite_stats.total }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">👥</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Utilisations totales</span>
|
||||
<span class="stat-value big">{{ invite_stats.total_uses }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">♾️</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Permanentes</span>
|
||||
<span class="stat-value big">{{ invite_stats.permanent }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⏳</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">Temporaires</span>
|
||||
<span class="stat-value big">{{ invite_stats.temporary }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if invite_stats.top_inviters %}
|
||||
<div class="moderation-insights">
|
||||
<div class="insight-card">
|
||||
<h3>🏆 Top inviteurs</h3>
|
||||
<div class="leaderboard">
|
||||
{% for inviter_name, uses in invite_stats.top_inviters %}
|
||||
<div class="leaderboard-item">
|
||||
<span class="rank">{{ loop.index }}</span>
|
||||
<span class="name">{{ inviter_name }}</span>
|
||||
<span class="count">{{ uses }} membre{{ 's' if uses > 1 else '' }} invité{{ 's' if uses > 1 else '' }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Liste des invitations -->
|
||||
{% if invites %}
|
||||
<table class="moderation invites-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Canal</th>
|
||||
<th>Créateur</th>
|
||||
<th>Utilisations</th>
|
||||
<th>Max utilisations</th>
|
||||
<th>Expiration</th>
|
||||
<th>Créée le</th>
|
||||
<th>Statut</th>
|
||||
<th>#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for invite in invites %}
|
||||
<tr class="{{ 'revoked' if invite.revoked else '' }} {{ 'expired' if invite.is_expired else '' }}">
|
||||
<td><code>{{ invite.code }}</code></td>
|
||||
<td>{{ invite.channel_name or 'N/A' }}</td>
|
||||
<td>{{ invite.inviter_name or 'Inconnu' }}</td>
|
||||
<td>{{ invite.uses or 0 }}</td>
|
||||
<td>{{ invite.max_uses if invite.max_uses > 0 else '∞' }}</td>
|
||||
<td>
|
||||
{% if invite.max_age == 0 %}
|
||||
<span class="badge permanent">Jamais</span>
|
||||
{% elif invite.expires_at %}
|
||||
{{ invite.expires_at.strftime('%d/%m/%Y %H:%M') }}
|
||||
{% else %}
|
||||
N/A
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ invite.created_at.strftime('%d/%m/%Y %H:%M') if invite.created_at else 'N/A' }}</td>
|
||||
<td>
|
||||
{% if invite.revoked %}
|
||||
<span class="badge revoked">Révoquée</span>
|
||||
{% elif invite.is_expired %}
|
||||
<span class="badge expired">Expirée</span>
|
||||
{% elif invite.max_uses > 0 and invite.uses >= invite.max_uses %}
|
||||
<span class="badge used">Max atteint</span>
|
||||
{% else %}
|
||||
<span class="badge active">Active</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not invite.revoked %}
|
||||
<a href="{{ url_for('revoke_invitation', invite_code=invite.code) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir révoquer l\'invitation {{ invite.code }} ?')"
|
||||
class="icon" title="Révoquer">🗑</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔗</div>
|
||||
<p>Aucune invitation trouvée</p>
|
||||
<small>Cliquez sur "Synchroniser les invitations" pour récupérer les invitations depuis Discord</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function toggleRevoked() {
|
||||
const showRevoked = document.getElementById('show-revoked').checked;
|
||||
window.location.href = '{{ url_for("moderation_invitations") }}?show_revoked=' + showRevoked;
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<title>Mamie Henriette</title>
|
||||
<link rel="stylesheet" href="/static/css/mvp.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/style.css" />
|
||||
<link rel="icon" href="/static/ico/favicon.ico" type="image/x-icon">
|
||||
<link rel="shortcut icon" href="/static/ico/favicon.ico" type="image/x-icon">
|
||||
@@ -16,18 +18,62 @@
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/"><img src="/static/ico/favicon.ico"></a>
|
||||
<ul>
|
||||
<li><a href="/live-alert">Alerte live</a></li>
|
||||
<li><a href="/commandes">Commandes</a></li>
|
||||
<li><a href="/humeurs">Humeurs</a></li>
|
||||
<li><a href="/moderation">Modération</a></li>
|
||||
<li><a href="/protondb">ProtonDB</a></li>
|
||||
<li><a href="/configurations">Configurations</a></li>
|
||||
<a href="/" class="nav-logo">
|
||||
<img src="/static/ico/favicon.ico" alt="Mamie Henriette">
|
||||
<span>Mamie Henriette</span>
|
||||
</a>
|
||||
|
||||
<input type="checkbox" id="nav-toggle" class="nav-toggle">
|
||||
<label for="nav-toggle" class="nav-toggle-label">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</label>
|
||||
|
||||
<ul class="nav-menu">
|
||||
<li class="has-submenu">
|
||||
<a href="#">Discord</a>
|
||||
<ul class="submenu">
|
||||
<li><a href="/commandes">📝 Commandes</a></li>
|
||||
<li><a href="/humeurs">😊 Humeurs</a></li>
|
||||
<li><a href="/moderation">🛡️ Modération</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="has-submenu">
|
||||
<a href="#">Twitch</a>
|
||||
<ul class="submenu">
|
||||
<li><a href="/live-alert">📺 Alerte Live</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="has-submenu">
|
||||
<a href="#">YouTube</a>
|
||||
<ul class="submenu">
|
||||
<li><a href="/youtube-alert">🎬 Alertes Vidéos</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="has-submenu">
|
||||
<a href="#">Outils</a>
|
||||
<ul class="submenu">
|
||||
<li><a href="/freegames">🎮 Jeux Gratuits</a></li>
|
||||
<li><a href="/protondb">🐧 ProtonDB</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/configurations">⚙️ Config</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Alertes YouTube</h1>
|
||||
|
||||
<p>
|
||||
Liste des chaînes YouTube surveillées pour les alertes de nouvelles vidéos.
|
||||
Le bot vérifie régulièrement les nouvelles vidéos publiées sur les chaînes configurées.
|
||||
Lorsqu'une nouvelle vidéo est détectée, le bot envoie une notification sur le canal Discord configuré.
|
||||
</p>
|
||||
|
||||
{% if not alert %}
|
||||
<h2>Chaînes surveillées</h2>
|
||||
<table class="youtube-alert">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chaîne</th>
|
||||
<th>Canal Discord</th>
|
||||
<th>Message</th>
|
||||
<th>Dernière vidéo</th>
|
||||
<th>#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for alert in alerts %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.youtube.com/channel/{{alert.channel_id}}" target="_blank">
|
||||
{{alert.channel_name or alert.channel_id}}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{alert.notify_channel_name}}</td>
|
||||
<td>{{alert.message[:50]}}{% if alert.message|length > 50 %}...{% endif %}</td>
|
||||
<td class="last-video">
|
||||
{% if alert.last_video_title %}
|
||||
<a href="https://www.youtube.com/watch?v={{alert.last_video_id}}" target="_blank" title="{{alert.last_video_title}}">
|
||||
{{alert.last_video_title[:40]}}{% if alert.last_video_title|length > 40 %}...{% endif %}
|
||||
</a>
|
||||
<br>
|
||||
<small>📅 {{alert.last_video_date.strftime('%d/%m/%Y à %H:%M') if alert.last_video_date else '-'}}</small>
|
||||
{% else %}
|
||||
<span class="no-video">Aucune vidéo détectée</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('toggleYoutubeAlert', id = alert.id) }}" class="icon">{{ '✅' if alert.enable else '❌' }}</a>
|
||||
<a href="{{ url_for('openEditYoutubeAlert', id = alert.id) }}" class="icon">✐</a>
|
||||
<a href="{{ url_for('delYoutubeAlert', id = alert.id) }}"
|
||||
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette alerte ?')" class="icon">🗑</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<h2>{{ 'Editer une alerte' if alert else 'Ajouter une alerte YouTube' }}</h2>
|
||||
<form action="{{ url_for('submitEditYoutubeAlert', id = alert.id) if alert else url_for('addYoutubeAlert') }}" method="POST">
|
||||
<label for="channel_id">ID de la chaîne YouTube</label>
|
||||
<input name="channel_id" type="text" maxlength="64" required="required" value="{{alert.channel_id if alert}}" placeholder="UCxxxxxxxxxxxxxxxxxxxxxxxx"/>
|
||||
|
||||
<label for="notify_channel">Canal de Notification Discord</label>
|
||||
<select name="notify_channel">
|
||||
{% for channel in channels %}
|
||||
<option value="{{channel.id}}"{% if alert and alert.notify_channel == channel.id %}
|
||||
selected="selected" {% endif %}>{{channel.name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label for="message">Message</label>
|
||||
<textarea name="message" rows="5" cols="50" required="required">{{alert.message if alert else '🎬 **Nouvelle vidéo !**\n\n**{0.author}** vient de publier : **{0.title}**\n\n👉 {0.link}'}}</textarea>
|
||||
|
||||
<input type="Submit" value="{{ 'Modifier' if alert else 'Ajouter' }}">
|
||||
|
||||
<h3>Comment trouver l'ID de chaîne YouTube ?</h3>
|
||||
<p>
|
||||
L'ID de chaîne YouTube commence par <code>UC</code> et contient 24 caractères.
|
||||
</p>
|
||||
<ol>
|
||||
<li>Allez sur la page de la chaîne YouTube</li>
|
||||
<li>Cliquez droit sur la page et choisissez "Afficher le code source"</li>
|
||||
<li>Recherchez <code>channelId</code> ou <code>externalId</code></li>
|
||||
<li>Copiez l'ID qui ressemble à : <code>UCxxxxxxxxxxxxxxxxxxxxxxxx</code></li>
|
||||
</ol>
|
||||
<p>
|
||||
Vous pouvez aussi utiliser des outils en ligne comme <a href="https://commentpicker.com/youtube-channel-id.php" target="_blank">Comment Picker</a> pour trouver l'ID.
|
||||
</p>
|
||||
|
||||
<h3>Variables disponibles pour le message</h3>
|
||||
<p>
|
||||
Pour le message vous avez accès à ces variables :
|
||||
</p>
|
||||
<ul>
|
||||
<li><code>{0.title}</code> : Titre de la vidéo</li>
|
||||
<li><code>{0.author}</code> : Nom de la chaîne</li>
|
||||
<li><code>{0.link}</code> : Lien vers la vidéo</li>
|
||||
<li><code>{0.video_id}</code> : ID de la vidéo</li>
|
||||
<li><code>{0.thumbnail}</code> : URL de la miniature</li>
|
||||
</ul>
|
||||
<p>
|
||||
Le message est au format <a href="https://commonmark.org/" target="_blank">common-mark</a> dans la limite de ce que supporte Discord.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
|
||||
from webapp import webapp
|
||||
from database import db
|
||||
from database.models import YoutubeAlert
|
||||
from discordbot import bot
|
||||
from youtubebot.youtube_alert import _get_channel_name
|
||||
|
||||
|
||||
@webapp.route("/youtube-alert")
|
||||
def openYoutubeAlert():
|
||||
alerts: list[YoutubeAlert] = YoutubeAlert.query.all()
|
||||
channels = bot.getAllTextChannel()
|
||||
for alert in alerts:
|
||||
for channel in channels:
|
||||
if alert.notify_channel == channel.id:
|
||||
alert.notify_channel_name = channel.name
|
||||
return render_template("youtube-alert.html", alerts=alerts, channels=channels)
|
||||
|
||||
|
||||
@webapp.route("/youtube-alert/add", methods=['POST'])
|
||||
def addYoutubeAlert():
|
||||
channel_id = request.form.get('channel_id').strip()
|
||||
|
||||
existing = YoutubeAlert.query.filter_by(channel_id=channel_id).first()
|
||||
if existing:
|
||||
flash("Cette chaîne YouTube est déjà surveillée.", "error")
|
||||
return redirect(url_for("openYoutubeAlert"))
|
||||
|
||||
channel_name = _get_channel_name(channel_id)
|
||||
if not channel_name:
|
||||
flash("Impossible de trouver cette chaîne YouTube. Vérifiez l'ID de la chaîne.", "error")
|
||||
return redirect(url_for("openYoutubeAlert"))
|
||||
|
||||
alert = YoutubeAlert(
|
||||
enable=True,
|
||||
channel_id=channel_id,
|
||||
channel_name=channel_name,
|
||||
notify_channel=request.form.get('notify_channel'),
|
||||
message=request.form.get('message')
|
||||
)
|
||||
db.session.add(alert)
|
||||
db.session.commit()
|
||||
flash(f"Alerte ajoutée pour la chaîne {channel_name}.", "success")
|
||||
return redirect(url_for("openYoutubeAlert"))
|
||||
|
||||
|
||||
@webapp.route("/youtube-alert/toggle/<int:id>")
|
||||
def toggleYoutubeAlert(id):
|
||||
alert: YoutubeAlert = YoutubeAlert.query.get_or_404(id)
|
||||
alert.enable = not alert.enable
|
||||
db.session.commit()
|
||||
return redirect(url_for("openYoutubeAlert"))
|
||||
|
||||
|
||||
@webapp.route("/youtube-alert/edit/<int:id>")
|
||||
def openEditYoutubeAlert(id):
|
||||
alert = YoutubeAlert.query.get_or_404(id)
|
||||
channels = bot.getAllTextChannel()
|
||||
return render_template("youtube-alert.html", alert=alert, channels=channels)
|
||||
|
||||
|
||||
@webapp.route("/youtube-alert/edit/<int:id>", methods=['POST'])
|
||||
def submitEditYoutubeAlert(id):
|
||||
alert: YoutubeAlert = YoutubeAlert.query.get_or_404(id)
|
||||
new_channel_id = request.form.get('channel_id').strip()
|
||||
|
||||
if new_channel_id != alert.channel_id:
|
||||
channel_name = _get_channel_name(new_channel_id)
|
||||
if not channel_name:
|
||||
flash("Impossible de trouver cette chaîne YouTube. Vérifiez l'ID de la chaîne.", "error")
|
||||
return redirect(url_for("openEditYoutubeAlert", id=id))
|
||||
alert.channel_id = new_channel_id
|
||||
alert.channel_name = channel_name
|
||||
alert.last_video_id = None
|
||||
|
||||
alert.notify_channel = request.form.get('notify_channel')
|
||||
alert.message = request.form.get('message')
|
||||
db.session.commit()
|
||||
return redirect(url_for("openYoutubeAlert"))
|
||||
|
||||
|
||||
@webapp.route("/youtube-alert/del/<int:id>")
|
||||
def delYoutubeAlert(id):
|
||||
alert = YoutubeAlert.query.get_or_404(id)
|
||||
db.session.delete(alert)
|
||||
db.session.commit()
|
||||
return redirect(url_for("openYoutubeAlert"))
|
||||
@@ -0,0 +1,102 @@
|
||||
import logging
|
||||
import feedparser
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from database import db
|
||||
from database.models import YoutubeAlert
|
||||
from webapp import webapp
|
||||
|
||||
logger = logging.getLogger('youtube-alert')
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
YOUTUBE_RSS_URL = "https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class YoutubeVideo:
|
||||
video_id: str
|
||||
title: str
|
||||
author: str
|
||||
link: str
|
||||
published: datetime
|
||||
thumbnail: str
|
||||
|
||||
|
||||
def _fetch_latest_video(channel_id: str) -> YoutubeVideo | None:
|
||||
feed_url = YOUTUBE_RSS_URL.format(channel_id=channel_id)
|
||||
try:
|
||||
feed = feedparser.parse(feed_url)
|
||||
if feed.entries:
|
||||
entry = feed.entries[0]
|
||||
video_id = entry.yt_videoid
|
||||
return YoutubeVideo(
|
||||
video_id=video_id,
|
||||
title=entry.title,
|
||||
author=entry.author,
|
||||
link=entry.link,
|
||||
published=datetime(*entry.published_parsed[:6]),
|
||||
thumbnail=f"https://i.ytimg.com/vi/{video_id}/maxresdefault.jpg"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la récupération du flux RSS pour {channel_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _get_channel_name(channel_id: str) -> str | None:
|
||||
feed_url = YOUTUBE_RSS_URL.format(channel_id=channel_id)
|
||||
try:
|
||||
feed = feedparser.parse(feed_url)
|
||||
if feed.feed and hasattr(feed.feed, 'author'):
|
||||
return feed.feed.author
|
||||
if feed.entries:
|
||||
return feed.entries[0].author
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la récupération du nom de chaîne pour {channel_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def checkNewVideos(bot):
|
||||
with webapp.app_context():
|
||||
alerts: list[YoutubeAlert] = YoutubeAlert.query.filter_by(enable=True).all()
|
||||
|
||||
for alert in alerts:
|
||||
logger.info(f"Vérification de la chaîne : {alert.channel_name or alert.channel_id}")
|
||||
video = _fetch_latest_video(alert.channel_id)
|
||||
|
||||
if video:
|
||||
if not alert.channel_name:
|
||||
alert.channel_name = video.author
|
||||
|
||||
if alert.last_video_id != video.video_id:
|
||||
logger.info(f"Nouvelle vidéo détectée : {video.title}")
|
||||
|
||||
if alert.last_video_id is not None:
|
||||
await _notifyAlert(bot, alert, video)
|
||||
|
||||
alert.last_video_id = video.video_id
|
||||
alert.last_video_title = video.title
|
||||
alert.last_video_date = video.published
|
||||
else:
|
||||
logger.warning(f"Impossible de récupérer les vidéos pour {alert.channel_id}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
async def _notifyAlert(bot, alert: YoutubeAlert, video: YoutubeVideo):
|
||||
try:
|
||||
message = alert.message.format(video)
|
||||
logger.info(f"Message de notification : {message}")
|
||||
bot.loop.create_task(_sendMessage(bot, alert.notify_channel, message))
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors du formatage du message : {e}")
|
||||
|
||||
|
||||
async def _sendMessage(bot, channel: int, message: str):
|
||||
logger.info(f"Envoi de notification : {message}")
|
||||
channel_obj = bot.get_channel(channel)
|
||||
if channel_obj:
|
||||
await channel_obj.send(message)
|
||||
logger.info("Notification envoyée")
|
||||
else:
|
||||
logger.error(f"Canal Discord non trouvé : {channel}")
|
||||
Reference in New Issue
Block a user