Ajout de la gestion des jeux gratuits et des invitations Discord. Création des modèles FreeGame et DiscordInvite, mise à jour de la base de données et des fichiers de configuration. Intégration de nouvelles fonctionnalités pour synchroniser et révoquer les invitations, ainsi que l'ajout d'un flux RSS pour les jeux gratuits. Amélioration de l'interface d'administration avec des statistiques et des options de gestion des serveurs.
This commit is contained in:
@@ -61,3 +61,35 @@ 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)
|
||||
|
||||
|
||||
@@ -76,3 +76,35 @@ 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
|
||||
);
|
||||
|
||||
+229
-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,53 @@ 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._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 +91,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 +110,11 @@ 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
|
||||
|
||||
def getAllTextChannel(self) -> list[TextChannel]:
|
||||
channels = []
|
||||
for channel in self.get_all_channels():
|
||||
@@ -72,7 +136,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 +464,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 +473,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+1388
-6
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,17 @@
|
||||
<p>Configurez les tokens Discord, les notifications Humble Bundle et l'API Twitch.</p>
|
||||
|
||||
<h2>Discord</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}" style="padding: 10px; margin: 10px 0; border-radius: 5px; {% if category == 'error' %}background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;{% else %}background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form action="{{ url_for('updateConfiguration') }}" method="POST">
|
||||
<fieldset>
|
||||
<legend>API Discord</legend>
|
||||
@@ -43,6 +54,87 @@
|
||||
</small>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Auto-Role</legend>
|
||||
<label for="autorole_enable">
|
||||
<input type="checkbox" name="autorole_enable" {% if configuration.getValue('autorole_enable') %}checked="checked"{% endif %}>
|
||||
Attribuer automatiquement un rôle aux nouveaux membres
|
||||
</label>
|
||||
|
||||
<label for="autorole_role_id">Rôle à attribuer</label>
|
||||
{% if roles|length > 1 %}
|
||||
<div class="tabs tabs-autorole">
|
||||
{% for guild_data in roles %}
|
||||
<button type="button" class="tab-button-autorole" onclick="openTabAutorole(event, 'autorole-guild-{{guild_data.guild_id}}')" {% if loop.first %}id="defaultOpenAutorole"{% endif %}>
|
||||
{{ guild_data.guild_name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for guild_data in roles %}
|
||||
<div id="autorole-guild-{{guild_data.guild_id}}" class="tab-content-autorole" {% if not loop.first %}style="display: none;"{% 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="selected"{% endif %}>
|
||||
{% if role.color.value != 0 %}⬤{% else %}○{% endif %} {{role.name}}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<small>
|
||||
<strong>Note :</strong> Le bot doit avoir la permission "Gérer les rôles" et son rôle doit être positionné plus haut que le rôle à attribuer dans la hiérarchie des rôles.
|
||||
</small>
|
||||
|
||||
<script>
|
||||
function openTabAutorole(evt, tabName) {
|
||||
var i, tabcontent, tabbuttons;
|
||||
tabcontent = document.getElementsByClassName("tab-content-autorole");
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].style.display = "none";
|
||||
}
|
||||
tabbuttons = document.getElementsByClassName("tab-button-autorole");
|
||||
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("defaultOpenAutorole")?.click();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.tabs-autorole {
|
||||
overflow: hidden;
|
||||
border-bottom: 2px solid #ccc;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tab-button-autorole {
|
||||
background-color: #f1f1f1;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 20px;
|
||||
transition: 0.3s;
|
||||
font-size: 14px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.tab-button-autorole:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
.tab-button-autorole.active {
|
||||
background-color: #ccc;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tab-content-autorole {
|
||||
animation: fadeEffect 0.3s;
|
||||
}
|
||||
</style>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Messages de départ</legend>
|
||||
<label for="leave_enable">
|
||||
@@ -237,4 +329,42 @@
|
||||
|
||||
<input type="Submit" value="Enregistrer la configuration Humble Bundle">
|
||||
</form>
|
||||
|
||||
<h2>⚠️ Gestion des serveurs</h2>
|
||||
<form action="{{ url_for('leaveGuild') }}" method="POST" onsubmit="return confirmLeave();">
|
||||
<fieldset>
|
||||
<legend>Quitter un serveur Discord</legend>
|
||||
<p style="color: #856404; background-color: #fff3cd; border: 1px solid #ffeeba; padding: 10px; border-radius: 5px;">
|
||||
<strong>⚠️ Attention :</strong> Cette action est irréversible ! Le bot quittera définitivement le serveur sélectionné.
|
||||
Pour rejoindre à nouveau, vous devrez ré-inviter le bot.
|
||||
</p>
|
||||
|
||||
{% 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>
|
||||
|
||||
<label for="confirm_leave" style="margin-top: 15px;">
|
||||
<input type="checkbox" name="confirm_leave" id="confirm_leave" value="1" required>
|
||||
Je confirme vouloir faire quitter le bot de ce serveur
|
||||
</label>
|
||||
|
||||
<input type="submit" value="🚪 Quitter le serveur" style="background-color: #dc3545; border-color: #dc3545;" />
|
||||
{% else %}
|
||||
<p>Aucun serveur Discord connecté ou le bot n'est pas encore démarré.</p>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function confirmLeave() {
|
||||
var select = document.getElementById('guild_id');
|
||||
var 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,56 @@
|
||||
<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="#">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>
|
||||
|
||||
Reference in New Issue
Block a user