3 Commits
28 changed files with 4908 additions and 929 deletions
+2
View File
@@ -22,11 +22,13 @@ RUN sed -i 's/# fr_FR.UTF-8 UTF-8/fr_FR.UTF-8 UTF-8/' /etc/locale.gen; \
COPY requirements.txt .
COPY run-web.py .
COPY shared_stats.py .
COPY ./database ./database
COPY ./discordbot ./discordbot
COPY ./protondb ./protondb
COPY ./webapp ./webapp
COPY ./twitchbot ./twitchbot
COPY ./youtubebot ./youtubebot
COPY start.sh /start.sh
RUN python3 -m venv /app/venv && \
+44
View File
@@ -61,3 +61,47 @@ class AntiCheatCache(db.Model):
notes = db.Column(db.String(1024))
updated_at = db.Column(db.DateTime)
class FreeGame(db.Model):
__tablename__ = 'free_game'
id = db.Column(db.Integer, primary_key=True)
entry_id = db.Column(db.String(512), unique=True)
title = db.Column(db.String(512))
source = db.Column(db.String(64))
url = db.Column(db.String(2048))
image_url = db.Column(db.String(2048))
description = db.Column(db.Text)
valid_from = db.Column(db.DateTime)
valid_to = db.Column(db.DateTime)
notified = db.Column(db.Boolean, default=False)
notified_at = db.Column(db.DateTime)
created_at = db.Column(db.DateTime)
class DiscordInvite(db.Model):
__tablename__ = 'discord_invite'
code = db.Column(db.String(32), primary_key=True)
guild_id = db.Column(db.String(64), nullable=False)
channel_id = db.Column(db.String(64), nullable=False)
channel_name = db.Column(db.String(256))
inviter_id = db.Column(db.String(64))
inviter_name = db.Column(db.String(256))
uses = db.Column(db.Integer, default=0)
max_uses = db.Column(db.Integer, default=0)
max_age = db.Column(db.Integer, default=0)
temporary = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime)
expires_at = db.Column(db.DateTime)
revoked = db.Column(db.Boolean, default=False)
last_sync = db.Column(db.DateTime)
class YoutubeAlert(db.Model):
__tablename__ = 'youtube_alert'
id = db.Column(db.Integer, primary_key=True)
enable = db.Column(db.Boolean, default=True)
channel_id = db.Column(db.String(64), unique=True)
channel_name = db.Column(db.String(256))
notify_channel = db.Column(db.Integer)
message = db.Column(db.String(2000))
last_video_id = db.Column(db.String(64))
last_video_title = db.Column(db.String(512))
last_video_date = db.Column(db.DateTime)
+44
View File
@@ -76,3 +76,47 @@ CREATE TABLE IF NOT EXISTS `member_invites` (
`inviter_name` VARCHAR(256),
`join_date` DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS `free_game` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`entry_id` VARCHAR(512) UNIQUE NOT NULL,
`title` VARCHAR(512) NOT NULL,
`source` VARCHAR(64) NOT NULL,
`url` VARCHAR(2048) NOT NULL,
`image_url` VARCHAR(2048),
`description` TEXT,
`valid_from` DATETIME,
`valid_to` DATETIME,
`notified` BOOLEAN NOT NULL DEFAULT FALSE,
`notified_at` DATETIME,
`created_at` DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS `discord_invite` (
`code` VARCHAR(32) PRIMARY KEY,
`guild_id` VARCHAR(64) NOT NULL,
`channel_id` VARCHAR(64) NOT NULL,
`channel_name` VARCHAR(256),
`inviter_id` VARCHAR(64),
`inviter_name` VARCHAR(256),
`uses` INTEGER NOT NULL DEFAULT 0,
`max_uses` INTEGER NOT NULL DEFAULT 0,
`max_age` INTEGER NOT NULL DEFAULT 0,
`temporary` BOOLEAN NOT NULL DEFAULT FALSE,
`created_at` DATETIME,
`expires_at` DATETIME,
`revoked` BOOLEAN NOT NULL DEFAULT FALSE,
`last_sync` DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS `youtube_alert` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`enable` BOOLEAN NOT NULL DEFAULT TRUE,
`channel_id` VARCHAR(64) UNIQUE NOT NULL,
`channel_name` VARCHAR(256),
`notify_channel` INTEGER NOT NULL,
`message` VARCHAR(2000) NOT NULL,
`last_video_id` VARCHAR(64),
`last_video_title` VARCHAR(512),
`last_video_date` DATETIME
);
+236 -3
View File
@@ -5,9 +5,11 @@ import random
from database import db
from database.helpers import ConfigurationHelper
from database.models import Configuration, Humeur, Commande
from database.models import Configuration, Humeur, Commande, DiscordInvite
from datetime import datetime, timezone, timedelta
from discord import Message, TextChannel, Member
from discordbot.humblebundle import checkHumbleBundleAndNotify
from discordbot.freegames import checkFreeGamesAndNotify
from discordbot.moderation import (
handle_warning_command,
handle_remove_warning_command,
@@ -22,7 +24,9 @@ from discordbot.moderation import (
handle_say_command
)
from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
from discordbot.autorole import assignAutoRole
from protondb import searhProtonDb
from shared_stats import stats_manager, discord_bridge
class DiscordBot(discord.Client):
async def on_ready(self):
@@ -33,8 +37,54 @@ class DiscordBot(discord.Client):
for guild in self.guilds:
await updateInviteCache(guild)
# Enregistrer le bot dans le bridge pour communication avec Flask
discord_bridge.register_bot(self, self.loop)
# Mise à jour des stats partagées
self._update_shared_stats()
# Synchronisation initiale des invitations
await self.syncInvites()
self.loop.create_task(self.updateStatus())
self.loop.create_task(self.updateHumbleBundle())
self.loop.create_task(self.updateFreeGames())
self.loop.create_task(self.updateYoutubeAlerts())
self.loop.create_task(self._periodic_stats_update())
def _update_shared_stats(self):
"""Met à jour les statistiques partagées"""
total_members = sum(g.member_count or 0 for g in self.guilds)
total_channels = len(list(self.get_all_channels()))
stats_manager.update_discord_stats(
connected=True,
guilds=len(self.guilds),
members=total_members,
channels=total_channels,
bot_name=str(self.user),
bot_id=self.user.id
)
# Mise à jour des cogs/fonctionnalités activées
helper = ConfigurationHelper()
cogs = {
'Modération': helper.getValue('moderation_enable') or False,
'Ban': helper.getValue('moderation_ban_enable') or False,
'Kick': helper.getValue('moderation_kick_enable') or False,
'ProtonDB': helper.getValue('proton_db_enable_enable') or False,
'Humeurs': True, # Toujours actif si le bot tourne
'Jeux Gratuits': helper.getValue('freegames_enable') or False,
'Messages de bienvenue': helper.getValue('welcome_enable') or False,
'Auto-Role': helper.getValue('autorole_enable') or False,
}
stats_manager.update_cogs(cogs)
async def _periodic_stats_update(self):
"""Met à jour les stats périodiquement"""
while not self.is_closed():
await asyncio.sleep(60) # Toutes les minutes
self._update_shared_stats()
async def updateStatus(self):
while not self.is_closed():
@@ -42,8 +92,18 @@ class DiscordBot(discord.Client):
if len(humeurs)>0 :
humeur = random.choice(humeurs)
if humeur != None:
logging.info(f'Changement de statut : {humeur.text}')
await self.change_presence(status = discord.Status.online, activity = discord.CustomActivity(humeur.text))
# Récupérer les stats pour les variables
total_members = sum(g.member_count or 0 for g in self.guilds)
total_channels = len(list(self.get_all_channels()))
# Remplacer les variables dans le texte
status_text = humeur.text
status_text = status_text.replace('{servers}', str(len(self.guilds)))
status_text = status_text.replace('{members}', str(total_members))
status_text = status_text.replace('{channels}', str(total_channels))
logging.info(f'Changement de statut : {status_text}')
await self.change_presence(status = discord.Status.online, activity = discord.CustomActivity(status_text))
await asyncio.sleep(10*60)
async def updateHumbleBundle(self):
@@ -51,6 +111,17 @@ class DiscordBot(discord.Client):
await checkHumbleBundleAndNotify(self)
await asyncio.sleep(30*60)
async def updateFreeGames(self):
while not self.is_closed():
await checkFreeGamesAndNotify(self)
await asyncio.sleep(60*60) # Vérification toutes les heures
async def updateYoutubeAlerts(self):
from youtubebot.youtube_alert import checkNewVideos
while not self.is_closed():
await checkNewVideos(self)
await asyncio.sleep(10*60) # Vérification toutes les 10 minutes
def getAllTextChannel(self) -> list[TextChannel]:
channels = []
for channel in self.get_all_channels():
@@ -72,7 +143,156 @@ class DiscordBot(discord.Client):
'roles': roles
})
return guilds_roles
def getAllGuilds(self):
"""Retourne la liste de tous les serveurs Discord"""
guilds_list = []
for guild in self.guilds:
guilds_list.append({
'id': guild.id,
'name': guild.name,
'member_count': guild.member_count,
'icon_url': str(guild.icon.url) if guild.icon else None,
'owner_id': guild.owner_id
})
return guilds_list
async def leaveGuild(self, guild_id: int) -> bool:
"""Quitte un serveur Discord par son ID"""
guild = self.get_guild(guild_id)
if guild:
await guild.leave()
logging.info(f'Le bot a quitté le serveur : {guild.name} (ID: {guild_id})')
return True
return False
async def syncInvites(self, guild_id: int = None) -> dict:
"""Synchronise les invitations Discord avec la base de données"""
result = {'synced': 0, 'errors': [], 'guilds': []}
guilds_to_sync = [self.get_guild(guild_id)] if guild_id else self.guilds
for guild in guilds_to_sync:
if not guild:
continue
try:
invites = await guild.invites()
now = datetime.now(timezone.utc)
# Marquer les invitations existantes comme révoquées (on les réactivera si elles existent encore)
existing_invites = DiscordInvite.query.filter_by(guild_id=str(guild.id), revoked=False).all()
existing_codes = {inv.code for inv in existing_invites}
current_codes = {inv.code for inv in invites}
# Marquer comme révoquées celles qui n'existent plus
for inv in existing_invites:
if inv.code not in current_codes:
inv.revoked = True
inv.last_sync = now
for invite in invites:
# Calculer la date d'expiration
expires_at = None
if invite.max_age and invite.max_age > 0 and invite.created_at:
expires_at = invite.created_at + timedelta(seconds=invite.max_age)
# Chercher si l'invitation existe déjà
db_invite = DiscordInvite.query.filter_by(code=invite.code).first()
if db_invite:
# Mettre à jour l'invitation existante
db_invite.uses = invite.uses or 0
db_invite.revoked = False
db_invite.last_sync = now
db_invite.channel_name = invite.channel.name if invite.channel else None
db_invite.inviter_name = invite.inviter.name if invite.inviter else None
else:
# Créer une nouvelle invitation
db_invite = DiscordInvite(
code=invite.code,
guild_id=str(guild.id),
channel_id=str(invite.channel.id) if invite.channel else '',
channel_name=invite.channel.name if invite.channel else None,
inviter_id=str(invite.inviter.id) if invite.inviter else None,
inviter_name=invite.inviter.name if invite.inviter else None,
uses=invite.uses or 0,
max_uses=invite.max_uses or 0,
max_age=invite.max_age or 0,
temporary=invite.temporary or False,
created_at=invite.created_at,
expires_at=expires_at,
revoked=False,
last_sync=now
)
db.session.add(db_invite)
result['synced'] += 1
db.session.commit()
result['guilds'].append({'id': guild.id, 'name': guild.name, 'invites': len(invites)})
logging.info(f'Invitations synchronisées pour {guild.name}: {len(invites)} invitations')
except Exception as e:
logging.error(f'Erreur lors de la synchronisation des invitations pour {guild.name}: {e}')
result['errors'].append(f'{guild.name}: {str(e)}')
return result
async def revokeInvite(self, invite_code: str) -> dict:
"""Révoque une invitation Discord"""
result = {'success': False, 'message': '', 'invite_code': invite_code}
try:
# Chercher l'invitation dans la BDD
db_invite = DiscordInvite.query.filter_by(code=invite_code).first()
if not db_invite:
result['message'] = 'Invitation non trouvée dans la base de données'
return result
# Récupérer le guild
guild = self.get_guild(int(db_invite.guild_id))
if not guild:
result['message'] = 'Serveur Discord non trouvé'
return result
# Chercher l'invitation sur Discord
try:
invites = await guild.invites()
discord_invite = next((inv for inv in invites if inv.code == invite_code), None)
if discord_invite:
await discord_invite.delete(reason='Révoquée via interface web')
logging.info(f'Invitation {invite_code} révoquée sur Discord')
except Exception as e:
logging.warning(f'Impossible de révoquer l\'invitation sur Discord: {e}')
# Marquer comme révoquée dans la BDD
db_invite.revoked = True
db_invite.last_sync = datetime.now(timezone.utc)
db.session.commit()
result['success'] = True
result['message'] = f'Invitation {invite_code} révoquée avec succès'
except Exception as e:
logging.error(f'Erreur lors de la révocation de l\'invitation {invite_code}: {e}')
result['message'] = f'Erreur: {str(e)}'
return result
def getInvites(self, guild_id: int = None, include_revoked: bool = False) -> list:
"""Récupère les invitations depuis la base de données"""
query = DiscordInvite.query
if guild_id:
query = query.filter_by(guild_id=str(guild_id))
if not include_revoked:
query = query.filter_by(revoked=False)
return query.order_by(DiscordInvite.created_at.desc()).all()
def begin(self) :
token = Configuration.query.filter_by(key='discord_token').first()
@@ -251,6 +471,7 @@ async def on_message(message: Message):
@bot.event
async def on_member_join(member: Member):
await sendWelcomeMessage(bot, member)
await assignAutoRole(bot, member)
@bot.event
async def on_member_remove(member: Member):
@@ -259,8 +480,20 @@ async def on_member_remove(member: Member):
@bot.event
async def on_invite_create(invite):
await updateInviteCache(invite.guild)
# Synchroniser la nouvelle invitation avec la BDD
await bot.syncInvites(invite.guild.id)
@bot.event
async def on_invite_delete(invite):
await updateInviteCache(invite.guild)
# Marquer l'invitation comme révoquée dans la BDD
try:
db_invite = DiscordInvite.query.filter_by(code=invite.code).first()
if db_invite:
db_invite.revoked = True
db_invite.last_sync = datetime.now(timezone.utc)
db.session.commit()
logging.info(f'Invitation {invite.code} marquée comme révoquée')
except Exception as e:
logging.error(f'Erreur lors de la révocation de l\'invitation {invite.code}: {e}')
+33
View File
@@ -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}')
+270
View File
@@ -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
+5 -4
View File
@@ -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)
+5 -1
View File
@@ -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}')
+3
View File
@@ -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
View File
@@ -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()
+4 -1
View File
@@ -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
View File
@@ -1,5 +1,7 @@
from flask import Flask
import os
webapp = Flask(__name__)
webapp.secret_key = os.environ.get('FLASK_SECRET_KEY', 'mamie-henriette-secret-key-change-me')
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, freegames, youtube_alert
+29 -3
View File
@@ -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'))
+133
View File
@@ -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
View File
@@ -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
View File
@@ -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)
-603
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+464 -228
View File
@@ -1,240 +1,476 @@
{% extends "template.html" %}
{% block content %}
<h1>Configuration de Mamie</h1>
<p>Configurez les tokens Discord, les notifications Humble Bundle et l'API Twitch.</p>
<div class="config-page">
<h1>⚙️ Configuration</h1>
<p>Paramétrez Discord, Twitch, YouTube et les fonctionnalités du bot.</p>
<h2>Discord</h2>
<form action="{{ url_for('updateConfiguration') }}" method="POST">
<fieldset>
<legend>API Discord</legend>
<label for="discord_token">Token Discord (caché)</label>
<input name="discord_token" type="password" placeholder="Votre token Discord" />
<small>Nécessite un redémarrage après modification</small>
</fieldset>
<fieldset>
<legend>Messages de bienvenue</legend>
<label for="welcome_enable">
<input type="checkbox" name="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked="checked"{% endif %}>
Activer le message de bienvenue pour les nouveaux membres
</label>
<label for="welcome_channel_id">Canal de bienvenue</label>
<select name="welcome_channel_id">
{% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('welcome_channel_id')==channel.id %}selected="selected"{% endif %}>
{{channel.name}}
</option>
{% endfor %}
</select>
<label for="welcome_message">Message personnalisé de bienvenue</label>
<textarea name="welcome_message" rows="3" placeholder="Bienvenue {member.mention} sur le serveur !">{{ configuration.getValue('welcome_message') }}</textarea>
<small>
<strong>Syntaxes disponibles :</strong><br>
<code>{member.mention}</code> - Mentionne l'utilisateur (@NomUtilisateur)<br>
<code>{member.name}</code> - Nom d'utilisateur (sans mention)<br>
<code>{member.display_name}</code> - Surnom sur le serveur<br>
<code>{member.id}</code> - ID de l'utilisateur<br>
<code>{server.name}</code> - Nom du serveur<br>
<code>{server.member_count}</code> - Nombre total de membres<br>
<code>&lt;#ID_DU_CHANNEL&gt;</code> - Mentionne un salon (ex: &lt;#123456789012345678&gt;)
</small>
</fieldset>
<fieldset>
<legend>Messages de départ</legend>
<label for="leave_enable">
<input type="checkbox" name="leave_enable" {% if configuration.getValue('leave_enable') %}checked="checked"{% endif %}>
Activer le message de départ quand un membre quitte le serveur
</label>
<label for="leave_channel_id">Canal de départ</label>
<select name="leave_channel_id">
{% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('leave_channel_id')==channel.id %}selected="selected"{% endif %}>
{{channel.name}}
</option>
{% endfor %}
</select>
<label for="leave_message">Message personnalisé de départ</label>
<textarea name="leave_message" rows="3" placeholder="{member.mention} a quitté le serveur.">{{ configuration.getValue('leave_message') }}</textarea>
<small>
<strong>Syntaxes disponibles :</strong><br>
<code>{member.mention}</code> - Mentionne l'utilisateur (@NomUtilisateur)<br>
<code>{member.name}</code> - Nom d'utilisateur (sans mention)<br>
<code>{member.display_name}</code> - Surnom sur le serveur<br>
<code>{member.id}</code> - ID de l'utilisateur<br>
<code>{server.name}</code> - Nom du serveur<br>
<code>{server.member_count}</code> - Nombre total de membres<br>
<code>&lt;#ID_DU_CHANNEL&gt;</code> - Mentionne un salon (ex: &lt;#123456789012345678&gt;)
</small>
</fieldset>
<fieldset>
<legend>Modération</legend>
<label for="moderation_enable">
<input type="checkbox" name="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked="checked"{% endif %}>
Activer les commandes d'avertissement (!warn, !unwarn, !inspect)
</label>
<label for="moderation_ban_enable">
<input type="checkbox" name="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked="checked"{% endif %}>
Activer les commandes de bannissement (!ban, !unban)
</label>
<label for="moderation_kick_enable">
<input type="checkbox" name="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked="checked"{% endif %}>
Activer la commande d'expulsion (!kick)
</label>
<label for="moderation_log_channel_id">Canal de logs de modération</label>
<select name="moderation_log_channel_id">
{% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('moderation_log_channel_id')==channel.id %}selected="selected"{% endif %}>
{{channel.name}}
</option>
{% endfor %}
</select>
<small>Toutes les actions de modération seront notifiées dans ce canal</small>
<label>Rôles Staff autorisés</label>
{% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %}
{% if roles|length > 1 %}
<div class="tabs">
{% for guild_data in roles %}
<button type="button" class="tab-button" onclick="openTab(event, 'guild-{{guild_data.guild_id}}')" {% if loop.first %}id="defaultOpen"{% endif %}>
{{ guild_data.guild_name }}
</button>
{% endfor %}
</div>
{% endif %}
{% for guild_data in roles %}
<div id="guild-{{guild_data.guild_id}}" class="tab-content" {% if not loop.first %}style="display: none;"{% endif %}>
<div style="max-height: 300px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; border-radius: 5px;">
{% for role in guild_data.roles %}
<label style="display: block; margin: 5px 0;">
<input type="checkbox" name="moderation_staff_role_ids" value="{{role.id}}" {% if role.id|string in selected_roles %}checked="checked"{% endif %}>
{% if role.color.value != 0 %}
<span style="color:#{{ '%06x' % role.color.value }}"></span>
{% else %}
<span></span>
{% endif %}
{{role.name}}
</label>
{% endfor %}
<!-- Section Discord -->
<div class="config-section open" data-section="discord">
<div class="config-section-header" onclick="toggleSection(this)">
<div class="config-section-icon">🎮</div>
<div class="config-section-title">
<h2>Discord</h2>
<p>Token API, messages de bienvenue, auto-rôle et modération</p>
</div>
<svg class="config-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
{% endfor %}
<small>Sélectionnez un ou plusieurs rôles qui peuvent utiliser les commandes de modération</small>
<script>
function openTab(evt, tabName) {
var i, tabcontent, tabbuttons;
tabcontent = document.getElementsByClassName("tab-content");
for (i = 0; i < tabcontent.length; i++) {
tabcontent[i].style.display = "none";
}
tabbuttons = document.getElementsByClassName("tab-button");
for (i = 0; i < tabbuttons.length; i++) {
tabbuttons[i].className = tabbuttons[i].className.replace(" active", "");
}
document.getElementById(tabName).style.display = "block";
evt.currentTarget.className += " active";
}
document.getElementById("defaultOpen")?.click();
</script>
<style>
.tabs {
overflow: hidden;
border-bottom: 2px solid #ccc;
margin-bottom: 10px;
}
.tab-button {
background-color: #f1f1f1;
border: none;
outline: none;
cursor: pointer;
padding: 10px 20px;
transition: 0.3s;
font-size: 14px;
margin-right: 2px;
}
.tab-button:hover {
background-color: #ddd;
}
.tab-button.active {
background-color: #ccc;
font-weight: bold;
}
.tab-content {
animation: fadeEffect 0.3s;
}
@keyframes fadeEffect {
from {opacity: 0;}
to {opacity: 1;}
}
</style>
<label for="moderation_embed_delete_delay">Délai de suppression des embeds (en secondes)</label>
<input name="moderation_embed_delete_delay" type="number" value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" placeholder="0" min="0" />
<small>Mettre 0 pour ne pas supprimer automatiquement</small>
</fieldset>
<div class="config-section-content">
<form action="{{ url_for('updateConfiguration') }}" method="POST">
<!-- Token Discord -->
<div class="config-card">
<div class="config-card-header">
<h3>🔑 Token Discord</h3>
<span class="badge">Sensible</span>
</div>
<label for="discord_token">Token du bot (caché)</label>
<div class="token-input-wrapper">
<input name="discord_token" id="discord_token" type="password" placeholder="Votre token Discord" autocomplete="off" />
<button type="button" class="token-toggle" onclick="toggleTokenVisibility('discord_token', this)" title="Afficher/Masquer">👁️</button>
</div>
<small>⚠️ Nécessite un redémarrage du bot après modification</small>
</div>
<input type="Submit" value="Enregistrer la configuration Discord">
</form>
<!-- Messages de bienvenue -->
<div class="config-card">
<div class="config-card-header">
<h3>👋 Messages de bienvenue</h3>
</div>
<div class="toggle-row">
<label for="welcome_enable">Activer le message de bienvenue pour les nouveaux membres</label>
<div class="toggle-switch">
<input type="checkbox" name="welcome_enable" id="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked{% endif %}>
<span class="toggle-slider"></span>
</div>
</div>
<label for="welcome_channel_id">Canal de bienvenue</label>
<select name="welcome_channel_id" id="welcome_channel_id">
{% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('welcome_channel_id')==channel.id %}selected{% endif %}>
#{{channel.name}}
</option>
{% endfor %}
</select>
<label for="welcome_message">Message personnalisé</label>
<textarea name="welcome_message" id="welcome_message" rows="3" placeholder="Bienvenue {member.mention} sur le serveur !">{{ configuration.getValue('welcome_message') }}</textarea>
<details class="syntax-helper">
<summary>📝 Variables disponibles</summary>
<div class="syntax-list">
<div class="syntax-item">
<code>{member.mention}</code>
<span>Mentionne l'utilisateur (@NomUtilisateur)</span>
</div>
<div class="syntax-item">
<code>{member.name}</code>
<span>Nom d'utilisateur (sans mention)</span>
</div>
<div class="syntax-item">
<code>{member.display_name}</code>
<span>Surnom sur le serveur</span>
</div>
<div class="syntax-item">
<code>{member.id}</code>
<span>ID de l'utilisateur</span>
</div>
<div class="syntax-item">
<code>{server.name}</code>
<span>Nom du serveur</span>
</div>
<div class="syntax-item">
<code>{server.member_count}</code>
<span>Nombre total de membres</span>
</div>
<div class="syntax-item">
<code>&lt;#ID_DU_CHANNEL&gt;</code>
<span>Mentionne un salon</span>
</div>
</div>
</details>
</div>
<h2>API Twitch</h2>
<form action="{{ url_for('updateConfiguration') }}" method="POST">
<label for="twitch_client_id">Client ID</label>
<input name="twitch_client_id" type="text" value="{{ configuration.getValue('twitch_client_id') }}" />
<label for="twitch_client_secret">Client Secret</label>
<input name="twitch_client_secret" type="text" value="{{ configuration.getValue('twitch_client_secret') }}" />
<label for="twitch_channel">Chaîne à rejoindre</label>
<input name="twitch_channel" type="text" value="{{ configuration.getValue('twitch_channel') }}"
placeholder="#machinTruc" />
<input type="Submit" value="Enregistrer la configuration Twitch">
<p>
<a href="{{ url_for('twitchConfigurationHelp') }}">Aide</a>
</p>
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
<p>
<a href="{{ url_for('twitchRequestToken') }}">Obtenir token et refresh token</a>
</p>
<label for="twitch_access_token">Access Token</label>
<input name="twitch_access_token" type="text" value="{{ configuration.getValue('twitch_access_token') }}"
readonly="readonly" />
<label for="twitch_refresh_token">Refresh Token</label>
<input name="twitch_refresh_token" type="text" value="{{ configuration.getValue('twitch_refresh_token') }}"
readonly="readonly" />
<p>Nécessite un redémarrage après l'obtention des Tokens.</p>
{% endif %}
</form>
<!-- Auto-Role -->
<div class="config-card">
<div class="config-card-header">
<h3>🎭 Auto-Rôle</h3>
</div>
<div class="toggle-row">
<label for="autorole_enable">Attribuer automatiquement un rôle aux nouveaux membres</label>
<div class="toggle-switch">
<input type="checkbox" name="autorole_enable" id="autorole_enable" {% if configuration.getValue('autorole_enable') %}checked{% endif %}>
<span class="toggle-slider"></span>
</div>
</div>
<label>Rôle à attribuer</label>
{% if roles|length > 1 %}
<div class="guild-tabs">
{% for guild_data in roles %}
<button type="button" class="guild-tab {% if loop.first %}active{% endif %}" onclick="switchGuildTab(this, 'autorole-guild-{{guild_data.guild_id}}')">
{{ guild_data.guild_name }}
</button>
{% endfor %}
</div>
{% endif %}
{% for guild_data in roles %}
<div id="autorole-guild-{{guild_data.guild_id}}" class="guild-content {% if loop.first %}active{% endif %}">
<select name="autorole_role_id">
<option value="">-- Aucun rôle --</option>
{% for role in guild_data.roles %}
<option value="{{role.id}}" {% if configuration.getIntValue('autorole_role_id')==role.id %}selected{% endif %}>
{% if role.color.value != 0 %}⬤{% else %}○{% endif %} {{role.name}}
</option>
{% endfor %}
</select>
</div>
{% endfor %}
<div class="warning-box">
<span class="warning-box-icon">⚠️</span>
<div class="warning-box-content">
<strong>Permissions requises</strong>
<p>Le bot doit avoir "Gérer les rôles" et son rôle doit être plus haut dans la hiérarchie que le rôle à attribuer.</p>
</div>
</div>
</div>
<h2>Humble Bundle</h2>
<form action="{{ url_for('updateConfiguration') }}" method="POST">
<p>Humble Bundle propose régulièrement des bundles de jeux vidéo à des prix réduits. Activez les notifications pour recevoir automatiquement les nouveaux packs disponibles sur votre serveur Discord.</p>
<!-- Messages de départ -->
<div class="config-card">
<div class="config-card-header">
<h3>🚪 Messages de départ</h3>
</div>
<div class="toggle-row">
<label for="leave_enable">Activer le message quand un membre quitte le serveur</label>
<div class="toggle-switch">
<input type="checkbox" name="leave_enable" id="leave_enable" {% if configuration.getValue('leave_enable') %}checked{% endif %}>
<span class="toggle-slider"></span>
</div>
</div>
<label for="leave_channel_id">Canal de départ</label>
<select name="leave_channel_id" id="leave_channel_id">
{% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('leave_channel_id')==channel.id %}selected{% endif %}>
#{{channel.name}}
</option>
{% endfor %}
</select>
<label for="leave_message">Message personnalisé</label>
<textarea name="leave_message" id="leave_message" rows="3" placeholder="{member.name} a quitté le serveur.">{{ configuration.getValue('leave_message') }}</textarea>
<details class="syntax-helper">
<summary>📝 Variables disponibles</summary>
<div class="syntax-list">
<div class="syntax-item">
<code>{member.mention}</code>
<span>Mentionne l'utilisateur</span>
</div>
<div class="syntax-item">
<code>{member.name}</code>
<span>Nom d'utilisateur</span>
</div>
<div class="syntax-item">
<code>{member.display_name}</code>
<span>Surnom sur le serveur</span>
</div>
<div class="syntax-item">
<code>{server.name}</code>
<span>Nom du serveur</span>
</div>
<div class="syntax-item">
<code>{server.member_count}</code>
<span>Nombre de membres restants</span>
</div>
</div>
</details>
</div>
<label for="humble_bundle_enable">
<input type="checkbox" name="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked="checked"{% endif %}>
Activer les notifications Humble Bundle
</label>
<!-- Modération -->
<div class="config-card">
<div class="config-card-header">
<h3>🛡️ Modération</h3>
</div>
<div class="toggle-row">
<label for="moderation_enable">Activer les commandes d'avertissement (!warn, !unwarn, !inspect)</label>
<div class="toggle-switch">
<input type="checkbox" name="moderation_enable" id="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked{% endif %}>
<span class="toggle-slider"></span>
</div>
</div>
<div class="toggle-row">
<label for="moderation_ban_enable">Activer les commandes de bannissement (!ban, !unban)</label>
<div class="toggle-switch">
<input type="checkbox" name="moderation_ban_enable" id="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked{% endif %}>
<span class="toggle-slider"></span>
</div>
</div>
<div class="toggle-row">
<label for="moderation_kick_enable">Activer la commande d'expulsion (!kick)</label>
<div class="toggle-switch">
<input type="checkbox" name="moderation_kick_enable" id="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked{% endif %}>
<span class="toggle-slider"></span>
</div>
</div>
<label for="moderation_log_channel_id">Canal de logs de modération</label>
<select name="moderation_log_channel_id" id="moderation_log_channel_id">
{% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('moderation_log_channel_id')==channel.id %}selected{% endif %}>
#{{channel.name}}
</option>
{% endfor %}
</select>
<small>Toutes les actions de modération seront notifiées dans ce canal</small>
<label>Rôles Staff autorisés</label>
{% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %}
{% if roles|length > 1 %}
<div class="guild-tabs">
{% for guild_data in roles %}
<button type="button" class="guild-tab {% if loop.first %}active{% endif %}" onclick="switchGuildTab(this, 'moderation-guild-{{guild_data.guild_id}}')">
{{ guild_data.guild_name }}
</button>
{% endfor %}
</div>
{% endif %}
{% for guild_data in roles %}
<div id="moderation-guild-{{guild_data.guild_id}}" class="guild-content {% if loop.first %}active{% endif %}">
<div class="roles-container">
{% for role in guild_data.roles %}
<div class="role-item">
<input type="checkbox" name="moderation_staff_role_ids" id="role-{{role.id}}" value="{{role.id}}" {% if role.id|string in selected_roles %}checked{% endif %}>
<label for="role-{{role.id}}">
<span class="role-color" style="background-color: {% if role.color.value != 0 %}#{{ '%06x' % role.color.value }}{% else %}var(--text-muted){% endif %};"></span>
{{role.name}}
</label>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
<small>Sélectionnez les rôles qui peuvent utiliser les commandes de modération</small>
<label for="moderation_embed_delete_delay">Délai de suppression des embeds (secondes)</label>
<input name="moderation_embed_delete_delay" id="moderation_embed_delete_delay" type="number" value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" placeholder="0" min="0" />
<small>Mettre 0 pour ne jamais supprimer automatiquement</small>
</div>
<label for="humble_bundle_channel">Canal de notification</label>
<select name="humble_bundle_channel">
{% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('humble_bundle_channel')==channel.id %}selected="selected"{% endif %}>
{{channel.name}}
</option>
{% endfor %}
</select>
<div class="config-actions">
<input type="submit" value="💾 Enregistrer Discord">
</div>
</form>
</div>
</div>
<input type="Submit" value="Enregistrer la configuration Humble Bundle">
</form>
{% endblock %}
<!-- Section Twitch -->
<div class="config-section" data-section="twitch">
<div class="config-section-header" onclick="toggleSection(this)">
<div class="config-section-icon">📺</div>
<div class="config-section-title">
<h2>Twitch</h2>
<p>Configuration de l'API Twitch pour les alertes live</p>
</div>
<svg class="config-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
<div class="config-section-content">
<form action="{{ url_for('updateConfiguration') }}" method="POST">
<label for="twitch_client_id">Client ID</label>
<input name="twitch_client_id" id="twitch_client_id" type="text" value="{{ configuration.getValue('twitch_client_id') }}" placeholder="Votre Client ID Twitch" />
<label for="twitch_client_secret">Client Secret</label>
<div class="token-input-wrapper">
<input name="twitch_client_secret" id="twitch_client_secret" type="password" value="{{ configuration.getValue('twitch_client_secret') }}" placeholder="Votre Client Secret" />
<button type="button" class="token-toggle" onclick="toggleTokenVisibility('twitch_client_secret', this)" title="Afficher/Masquer">👁️</button>
</div>
<label for="twitch_channel">Chaîne à rejoindre</label>
<input name="twitch_channel" id="twitch_channel" type="text" value="{{ configuration.getValue('twitch_channel') }}" placeholder="#votre_chaine" />
<a href="{{ url_for('twitchConfigurationHelp') }}" class="help-link">
❓ Besoin d'aide pour configurer Twitch ?
</a>
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
<div class="config-card" style="margin-top: var(--space-lg);">
<div class="config-card-header">
<h3>🔐 Tokens d'accès</h3>
</div>
<a href="{{ url_for('twitchRequestToken') }}" class="btn" style="margin-bottom: var(--space-md); display: inline-flex;">
🔄 Obtenir/Rafraîchir les tokens
</a>
<label for="twitch_access_token">Access Token</label>
<input name="twitch_access_token" id="twitch_access_token" type="text" value="{{ configuration.getValue('twitch_access_token') }}" readonly />
<label for="twitch_refresh_token">Refresh Token</label>
<input name="twitch_refresh_token" id="twitch_refresh_token" type="text" value="{{ configuration.getValue('twitch_refresh_token') }}" readonly />
<small>⚠️ Nécessite un redémarrage après l'obtention des tokens</small>
</div>
{% endif %}
<div class="config-actions">
<input type="submit" value="💾 Enregistrer Twitch">
</div>
</form>
</div>
</div>
<!-- Section Humble Bundle -->
<div class="config-section" data-section="humble">
<div class="config-section-header" onclick="toggleSection(this)">
<div class="config-section-icon">🎁</div>
<div class="config-section-title">
<h2>Humble Bundle</h2>
<p>Notifications automatiques des nouveaux bundles de jeux</p>
</div>
<svg class="config-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
<div class="config-section-content">
<form action="{{ url_for('updateConfiguration') }}" method="POST">
<p style="color: var(--text-secondary); margin-bottom: var(--space-lg);">
Humble Bundle propose régulièrement des bundles de jeux vidéo à prix réduits. Activez les notifications pour recevoir automatiquement les nouveaux packs sur votre serveur Discord.
</p>
<div class="toggle-row">
<label for="humble_bundle_enable">Activer les notifications Humble Bundle</label>
<div class="toggle-switch">
<input type="checkbox" name="humble_bundle_enable" id="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked{% endif %}>
<span class="toggle-slider"></span>
</div>
</div>
<label for="humble_bundle_channel">Canal de notification</label>
<select name="humble_bundle_channel" id="humble_bundle_channel">
{% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('humble_bundle_channel')==channel.id %}selected{% endif %}>
#{{channel.name}}
</option>
{% endfor %}
</select>
<div class="config-actions">
<input type="submit" value="💾 Enregistrer Humble Bundle">
</div>
</form>
</div>
</div>
<!-- Section Zone Danger -->
<div class="config-section danger" data-section="danger">
<div class="config-section-header" onclick="toggleSection(this)">
<div class="config-section-icon">⚠️</div>
<div class="config-section-title">
<h2>Zone Danger</h2>
<p>Actions irréversibles - Utilisez avec précaution</p>
</div>
<svg class="config-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
<div class="config-section-content">
<form action="{{ url_for('leaveGuild') }}" method="POST" onsubmit="return confirmLeave();">
<div class="danger-box">
<span class="danger-box-icon">🚨</span>
<div class="danger-box-content">
<strong>Quitter un serveur Discord</strong>
<p>Cette action est irréversible ! Le bot quittera définitivement le serveur sélectionné. Pour rejoindre à nouveau, vous devrez ré-inviter le bot.</p>
</div>
</div>
{% if guilds and guilds|length > 0 %}
<label for="guild_id">Sélectionner un serveur à quitter</label>
<select name="guild_id" id="guild_id" required>
<option value="">-- Choisir un serveur --</option>
{% for guild in guilds %}
<option value="{{ guild.id }}">{{ guild.name }} ({{ guild.member_count }} membres)</option>
{% endfor %}
</select>
<div class="toggle-row" style="background: rgba(220, 38, 38, 0.1); margin-top: var(--space-md);">
<label for="confirm_leave" style="color: var(--danger);">Je confirme vouloir faire quitter le bot de ce serveur</label>
<div class="toggle-switch">
<input type="checkbox" name="confirm_leave" id="confirm_leave" value="1" required>
<span class="toggle-slider"></span>
</div>
</div>
<div class="config-actions">
<input type="submit" value="🚪 Quitter le serveur" class="btn-danger" style="flex: none;">
</div>
{% else %}
<p style="color: var(--text-muted); text-align: center; padding: var(--space-lg);">
Aucun serveur Discord connecté ou le bot n'est pas encore démarré.
</p>
{% endif %}
</form>
</div>
</div>
</div>
<script>
// Toggle section open/close
function toggleSection(header) {
const section = header.closest('.config-section');
section.classList.toggle('open');
}
// Toggle token visibility
function toggleTokenVisibility(inputId, button) {
const input = document.getElementById(inputId);
if (input.type === 'password') {
input.type = 'text';
button.textContent = '🙈';
} else {
input.type = 'password';
button.textContent = '👁️';
}
}
// Switch guild tabs
function switchGuildTab(button, contentId) {
// Get parent tabs container
const tabsContainer = button.closest('.guild-tabs');
const card = button.closest('.config-card');
// Deactivate all tabs in this group
tabsContainer.querySelectorAll('.guild-tab').forEach(tab => tab.classList.remove('active'));
button.classList.add('active');
// Hide all content in this card
card.querySelectorAll('.guild-content').forEach(content => content.classList.remove('active'));
document.getElementById(contentId).classList.add('active');
}
// Confirm leave guild
function confirmLeave() {
const select = document.getElementById('guild_id');
if (!select.value) {
alert('Veuillez sélectionner un serveur.');
return false;
}
const serverName = select.options[select.selectedIndex].text;
return confirm('Êtes-vous VRAIMENT sûr de vouloir quitter le serveur "' + serverName + '" ?\n\nCette action est IRRÉVERSIBLE !');
}
</script>
{% endblock %}
+381
View File
@@ -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 %}
+12
View File
@@ -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
View File
@@ -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 %}
+315 -56
View File
@@ -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 %}
+55 -9
View File
@@ -7,7 +7,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
<meta name="mobile-web-app-capable" content="yes" />
<title>Mamie Henriette</title>
<link rel="stylesheet" href="/static/css/mvp.css" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css" />
<link rel="icon" href="/static/ico/favicon.ico" type="image/x-icon">
<link rel="shortcut icon" href="/static/ico/favicon.ico" type="image/x-icon">
@@ -16,18 +18,62 @@
<body>
<header>
<nav>
<a href="/"><img src="/static/ico/favicon.ico"></a>
<ul>
<li><a href="/live-alert">Alerte live</a></li>
<li><a href="/commandes">Commandes</a></li>
<li><a href="/humeurs">Humeurs</a></li>
<li><a href="/moderation">Modération</a></li>
<li><a href="/protondb">ProtonDB</a></li>
<li><a href="/configurations">Configurations</a></li>
<a href="/" class="nav-logo">
<img src="/static/ico/favicon.ico" alt="Mamie Henriette">
<span>Mamie Henriette</span>
</a>
<input type="checkbox" id="nav-toggle" class="nav-toggle">
<label for="nav-toggle" class="nav-toggle-label">
<span></span>
<span></span>
<span></span>
</label>
<ul class="nav-menu">
<li class="has-submenu">
<a href="#">Discord</a>
<ul class="submenu">
<li><a href="/commandes">📝 Commandes</a></li>
<li><a href="/humeurs">😊 Humeurs</a></li>
<li><a href="/moderation">🛡️ Modération</a></li>
</ul>
</li>
<li class="has-submenu">
<a href="#">Twitch</a>
<ul class="submenu">
<li><a href="/live-alert">📺 Alerte Live</a></li>
</ul>
</li>
<li class="has-submenu">
<a href="#">YouTube</a>
<ul class="submenu">
<li><a href="/youtube-alert">🎬 Alertes Vidéos</a></li>
</ul>
</li>
<li class="has-submenu">
<a href="#">Outils</a>
<ul class="submenu">
<li><a href="/freegames">🎮 Jeux Gratuits</a></li>
<li><a href="/protondb">🐧 ProtonDB</a></li>
</ul>
</li>
<li>
<a href="/configurations">⚙️ Config</a>
</li>
</ul>
</nav>
</header>
<main>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash-message {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer>
+106
View File
@@ -0,0 +1,106 @@
{% extends "template.html" %}
{% block content %}
<h1>Alertes YouTube</h1>
<p>
Liste des chaînes YouTube surveillées pour les alertes de nouvelles vidéos.
Le bot vérifie régulièrement les nouvelles vidéos publiées sur les chaînes configurées.
Lorsqu'une nouvelle vidéo est détectée, le bot envoie une notification sur le canal Discord configuré.
</p>
{% if not alert %}
<h2>Chaînes surveillées</h2>
<table class="youtube-alert">
<thead>
<tr>
<th>Chaîne</th>
<th>Canal Discord</th>
<th>Message</th>
<th>Dernière vidéo</th>
<th>#</th>
</tr>
</thead>
<tbody>
{% for alert in alerts %}
<tr>
<td>
<a href="https://www.youtube.com/channel/{{alert.channel_id}}" target="_blank">
{{alert.channel_name or alert.channel_id}}
</a>
</td>
<td>{{alert.notify_channel_name}}</td>
<td>{{alert.message[:50]}}{% if alert.message|length > 50 %}...{% endif %}</td>
<td class="last-video">
{% if alert.last_video_title %}
<a href="https://www.youtube.com/watch?v={{alert.last_video_id}}" target="_blank" title="{{alert.last_video_title}}">
{{alert.last_video_title[:40]}}{% if alert.last_video_title|length > 40 %}...{% endif %}
</a>
<br>
<small>📅 {{alert.last_video_date.strftime('%d/%m/%Y à %H:%M') if alert.last_video_date else '-'}}</small>
{% else %}
<span class="no-video">Aucune vidéo détectée</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('toggleYoutubeAlert', id = alert.id) }}" class="icon">{{ '✅' if alert.enable else '❌' }}</a>
<a href="{{ url_for('openEditYoutubeAlert', id = alert.id) }}" class="icon"></a>
<a href="{{ url_for('delYoutubeAlert', id = alert.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette alerte ?')" class="icon">🗑</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h2>{{ 'Editer une alerte' if alert else 'Ajouter une alerte YouTube' }}</h2>
<form action="{{ url_for('submitEditYoutubeAlert', id = alert.id) if alert else url_for('addYoutubeAlert') }}" method="POST">
<label for="channel_id">ID de la chaîne YouTube</label>
<input name="channel_id" type="text" maxlength="64" required="required" value="{{alert.channel_id if alert}}" placeholder="UCxxxxxxxxxxxxxxxxxxxxxxxx"/>
<label for="notify_channel">Canal de Notification Discord</label>
<select name="notify_channel">
{% for channel in channels %}
<option value="{{channel.id}}"{% if alert and alert.notify_channel == channel.id %}
selected="selected" {% endif %}>{{channel.name}}</option>
{% endfor %}
</select>
<label for="message">Message</label>
<textarea name="message" rows="5" cols="50" required="required">{{alert.message if alert else '🎬 **Nouvelle vidéo !**\n\n**{0.author}** vient de publier : **{0.title}**\n\n👉 {0.link}'}}</textarea>
<input type="Submit" value="{{ 'Modifier' if alert else 'Ajouter' }}">
<h3>Comment trouver l'ID de chaîne YouTube ?</h3>
<p>
L'ID de chaîne YouTube commence par <code>UC</code> et contient 24 caractères.
</p>
<ol>
<li>Allez sur la page de la chaîne YouTube</li>
<li>Cliquez droit sur la page et choisissez "Afficher le code source"</li>
<li>Recherchez <code>channelId</code> ou <code>externalId</code></li>
<li>Copiez l'ID qui ressemble à : <code>UCxxxxxxxxxxxxxxxxxxxxxxxx</code></li>
</ol>
<p>
Vous pouvez aussi utiliser des outils en ligne comme <a href="https://commentpicker.com/youtube-channel-id.php" target="_blank">Comment Picker</a> pour trouver l'ID.
</p>
<h3>Variables disponibles pour le message</h3>
<p>
Pour le message vous avez accès à ces variables :
</p>
<ul>
<li><code>{0.title}</code> : Titre de la vidéo</li>
<li><code>{0.author}</code> : Nom de la chaîne</li>
<li><code>{0.link}</code> : Lien vers la vidéo</li>
<li><code>{0.video_id}</code> : ID de la vidéo</li>
<li><code>{0.thumbnail}</code> : URL de la miniature</li>
</ul>
<p>
Le message est au format <a href="https://commonmark.org/" target="_blank">common-mark</a> dans la limite de ce que supporte Discord.
</p>
</form>
{% endblock %}
+88
View File
@@ -0,0 +1,88 @@
from flask import render_template, request, redirect, url_for, flash
from webapp import webapp
from database import db
from database.models import YoutubeAlert
from discordbot import bot
from youtubebot.youtube_alert import _get_channel_name
@webapp.route("/youtube-alert")
def openYoutubeAlert():
alerts: list[YoutubeAlert] = YoutubeAlert.query.all()
channels = bot.getAllTextChannel()
for alert in alerts:
for channel in channels:
if alert.notify_channel == channel.id:
alert.notify_channel_name = channel.name
return render_template("youtube-alert.html", alerts=alerts, channels=channels)
@webapp.route("/youtube-alert/add", methods=['POST'])
def addYoutubeAlert():
channel_id = request.form.get('channel_id').strip()
existing = YoutubeAlert.query.filter_by(channel_id=channel_id).first()
if existing:
flash("Cette chaîne YouTube est déjà surveillée.", "error")
return redirect(url_for("openYoutubeAlert"))
channel_name = _get_channel_name(channel_id)
if not channel_name:
flash("Impossible de trouver cette chaîne YouTube. Vérifiez l'ID de la chaîne.", "error")
return redirect(url_for("openYoutubeAlert"))
alert = YoutubeAlert(
enable=True,
channel_id=channel_id,
channel_name=channel_name,
notify_channel=request.form.get('notify_channel'),
message=request.form.get('message')
)
db.session.add(alert)
db.session.commit()
flash(f"Alerte ajoutée pour la chaîne {channel_name}.", "success")
return redirect(url_for("openYoutubeAlert"))
@webapp.route("/youtube-alert/toggle/<int:id>")
def toggleYoutubeAlert(id):
alert: YoutubeAlert = YoutubeAlert.query.get_or_404(id)
alert.enable = not alert.enable
db.session.commit()
return redirect(url_for("openYoutubeAlert"))
@webapp.route("/youtube-alert/edit/<int:id>")
def openEditYoutubeAlert(id):
alert = YoutubeAlert.query.get_or_404(id)
channels = bot.getAllTextChannel()
return render_template("youtube-alert.html", alert=alert, channels=channels)
@webapp.route("/youtube-alert/edit/<int:id>", methods=['POST'])
def submitEditYoutubeAlert(id):
alert: YoutubeAlert = YoutubeAlert.query.get_or_404(id)
new_channel_id = request.form.get('channel_id').strip()
if new_channel_id != alert.channel_id:
channel_name = _get_channel_name(new_channel_id)
if not channel_name:
flash("Impossible de trouver cette chaîne YouTube. Vérifiez l'ID de la chaîne.", "error")
return redirect(url_for("openEditYoutubeAlert", id=id))
alert.channel_id = new_channel_id
alert.channel_name = channel_name
alert.last_video_id = None
alert.notify_channel = request.form.get('notify_channel')
alert.message = request.form.get('message')
db.session.commit()
return redirect(url_for("openYoutubeAlert"))
@webapp.route("/youtube-alert/del/<int:id>")
def delYoutubeAlert(id):
alert = YoutubeAlert.query.get_or_404(id)
db.session.delete(alert)
db.session.commit()
return redirect(url_for("openYoutubeAlert"))
View File
+102
View File
@@ -0,0 +1,102 @@
import logging
import feedparser
from dataclasses import dataclass
from datetime import datetime
from database import db
from database.models import YoutubeAlert
from webapp import webapp
logger = logging.getLogger('youtube-alert')
logger.setLevel(logging.INFO)
YOUTUBE_RSS_URL = "https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
@dataclass
class YoutubeVideo:
video_id: str
title: str
author: str
link: str
published: datetime
thumbnail: str
def _fetch_latest_video(channel_id: str) -> YoutubeVideo | None:
feed_url = YOUTUBE_RSS_URL.format(channel_id=channel_id)
try:
feed = feedparser.parse(feed_url)
if feed.entries:
entry = feed.entries[0]
video_id = entry.yt_videoid
return YoutubeVideo(
video_id=video_id,
title=entry.title,
author=entry.author,
link=entry.link,
published=datetime(*entry.published_parsed[:6]),
thumbnail=f"https://i.ytimg.com/vi/{video_id}/maxresdefault.jpg"
)
except Exception as e:
logger.error(f"Erreur lors de la récupération du flux RSS pour {channel_id}: {e}")
return None
def _get_channel_name(channel_id: str) -> str | None:
feed_url = YOUTUBE_RSS_URL.format(channel_id=channel_id)
try:
feed = feedparser.parse(feed_url)
if feed.feed and hasattr(feed.feed, 'author'):
return feed.feed.author
if feed.entries:
return feed.entries[0].author
except Exception as e:
logger.error(f"Erreur lors de la récupération du nom de chaîne pour {channel_id}: {e}")
return None
async def checkNewVideos(bot):
with webapp.app_context():
alerts: list[YoutubeAlert] = YoutubeAlert.query.filter_by(enable=True).all()
for alert in alerts:
logger.info(f"Vérification de la chaîne : {alert.channel_name or alert.channel_id}")
video = _fetch_latest_video(alert.channel_id)
if video:
if not alert.channel_name:
alert.channel_name = video.author
if alert.last_video_id != video.video_id:
logger.info(f"Nouvelle vidéo détectée : {video.title}")
if alert.last_video_id is not None:
await _notifyAlert(bot, alert, video)
alert.last_video_id = video.video_id
alert.last_video_title = video.title
alert.last_video_date = video.published
else:
logger.warning(f"Impossible de récupérer les vidéos pour {alert.channel_id}")
db.session.commit()
async def _notifyAlert(bot, alert: YoutubeAlert, video: YoutubeVideo):
try:
message = alert.message.format(video)
logger.info(f"Message de notification : {message}")
bot.loop.create_task(_sendMessage(bot, alert.notify_channel, message))
except Exception as e:
logger.error(f"Erreur lors du formatage du message : {e}")
async def _sendMessage(bot, channel: int, message: str):
logger.info(f"Envoi de notification : {message}")
channel_obj = bot.get_channel(channel)
if channel_obj:
await channel_obj.send(message)
logger.info("Notification envoyée")
else:
logger.error(f"Canal Discord non trouvé : {channel}")