Ajout de fonctionnalités majeures Twitch, Discord et interface web
Nouvelles fonctionnalités : - Système de modération Twitch complet (bans, timeouts, avertissements) - Filtre de liens intelligent pour Twitch avec whitelist/blacklist - Notifications d'événements Twitch (follows, subs, raids, etc.) - Système Freeloot Discord avec flux RSS dédié - Salons automatiques Discord (création/suppression dynamique) - Authentification utilisateur pour l'interface web (login/register) - Gestion des utilisateurs et permissions - Interface de modération Twitch dans la webapp - Interface de gestion des événements Twitch - Configuration des paramètres utilisateur - Migration BDD pour les mots bannis Améliorations de l'interface : - Refonte complète des templates (configurations, commandes, humeurs, etc.) - Nouvelle page de settings utilisateur - Page de gestion des utilisateurs (admin) - Page d'erreur 403 personnalisée - Amélioration de la navigation et du design global - Intégration de nouvelles sections dans le menu principal Modifications techniques : - Ajout de nouveaux modèles en base de données - Extension des helpers database - Mise à jour des dépendances (requirements.txt) - Amélioration de la gestion des annonces Twitch - Refactorisation du code pour meilleure maintenabilité Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+25
-2
@@ -7,8 +7,9 @@ from webapp import webapp
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import Configuration, Humeur, Commande
|
||||
from discord import Message, TextChannel, Member
|
||||
from discord import Message, TextChannel, Member, VoiceChannel
|
||||
from discordbot.humblebundle import checkHumbleBundleAndNotify
|
||||
from discordbot.freeloot import checkFreeLootAndNotify
|
||||
from discordbot.moderation import (
|
||||
handle_warning_command,
|
||||
handle_remove_warning_command,
|
||||
@@ -24,6 +25,7 @@ from discordbot.moderation import (
|
||||
)
|
||||
from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
|
||||
from discordbot.youtube import checkYouTubeVideos
|
||||
from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms
|
||||
from protondb import searhProtonDb
|
||||
|
||||
class DiscordBot(discord.Client):
|
||||
@@ -40,6 +42,7 @@ class DiscordBot(discord.Client):
|
||||
self.loop.create_task(self.updateStatus())
|
||||
self.loop.create_task(self.updateHumbleBundle())
|
||||
self.loop.create_task(self.updateYouTube())
|
||||
self.loop.create_task(self.updateFreeLoot())
|
||||
|
||||
async def on_disconnect(self):
|
||||
webapp.config["BOT_STATUS"]["discord_connected"] = False
|
||||
@@ -64,13 +67,25 @@ class DiscordBot(discord.Client):
|
||||
await checkYouTubeVideos()
|
||||
await asyncio.sleep(5*60)
|
||||
|
||||
async def updateFreeLoot(self):
|
||||
while not self.is_closed():
|
||||
await checkFreeLootAndNotify(self)
|
||||
await asyncio.sleep(30*60)
|
||||
|
||||
def getAllTextChannel(self) -> list[TextChannel]:
|
||||
channels = []
|
||||
for channel in self.get_all_channels():
|
||||
if isinstance(channel, TextChannel):
|
||||
channels.append(channel)
|
||||
return channels
|
||||
|
||||
|
||||
def getAllVoiceChannels(self) -> list[VoiceChannel]:
|
||||
channels = []
|
||||
for channel in self.get_all_channels():
|
||||
if isinstance(channel, VoiceChannel):
|
||||
channels.append(channel)
|
||||
return channels
|
||||
|
||||
def getAllRoles(self):
|
||||
guilds_roles = []
|
||||
for guild in self.guilds:
|
||||
@@ -261,6 +276,14 @@ async def on_message(message: Message):
|
||||
except Exception as e:
|
||||
logging.error(f"Échec de l'envoi de l'embed ProtonDB : {e}")
|
||||
|
||||
@bot.event
|
||||
async def on_voice_state_update(member: Member, before, after):
|
||||
await on_voice_state_update_auto_rooms(bot, member, before, after)
|
||||
|
||||
@bot.event
|
||||
async def on_raw_reaction_add(payload: discord.RawReactionActionEvent):
|
||||
await on_raw_reaction_add_auto_rooms(bot, payload)
|
||||
|
||||
@bot.event
|
||||
async def on_member_join(member: Member):
|
||||
await sendWelcomeMessage(bot, member)
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
# discordbot/auto_rooms.py — Auto rooms : message et réactions dans la partie texte du salon vocal (onglet Discussion)
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import discord
|
||||
from discord import Member, VoiceState
|
||||
from database.helpers import ConfigurationHelper
|
||||
|
||||
# (guild_id, owner_id) -> room_data (voice_channel_id, control_message_id, whitelist, blacklist, access_mode)
|
||||
_rooms: dict[tuple[int, int], dict] = {}
|
||||
|
||||
# message_id -> (guild_id, owner_id) pour retrouver la room depuis une réaction
|
||||
_control_message_ids: dict[int, tuple[int, int]] = {}
|
||||
|
||||
# Emoji -> action
|
||||
REACTIONS = [
|
||||
("🔓", "open", "Ouvert"),
|
||||
("🔒", "closed", "Fermé"),
|
||||
("🛡️", "private", "Privé"),
|
||||
("✅", "whitelist", "Liste blanche"),
|
||||
("🚫", "blacklist", "Liste noire"),
|
||||
("🧹", "purge", "Purge"),
|
||||
("👑", "transfer", "Propriété"),
|
||||
("🎤", "speak", "Micro"),
|
||||
("📹", "stream", "Vidéo"),
|
||||
("📝", "status", "Statut"),
|
||||
]
|
||||
|
||||
|
||||
def _status_display(access_mode: str) -> str:
|
||||
"""Cadenas ouvert ou fermé selon si le salon est ouvert ou pas."""
|
||||
if access_mode == "open":
|
||||
return "🔓 Ouvert"
|
||||
if access_mode == "closed":
|
||||
return "🔒 Fermé"
|
||||
if access_mode == "private":
|
||||
return "🔒 Privé"
|
||||
return "🔓 Ouvert"
|
||||
|
||||
|
||||
def _status_emoji(access_mode: str) -> str:
|
||||
"""Emoji cadenas seul pour le nom du channel."""
|
||||
return "🔓" if access_mode == "open" else "🔒"
|
||||
|
||||
|
||||
def _build_control_embed(owner: Member, voice_channel: discord.VoiceChannel, access_mode: str) -> discord.Embed:
|
||||
"""Construit l’embed de config avec infos du salon."""
|
||||
embed = discord.Embed(
|
||||
title="Configuration du salon",
|
||||
description=(
|
||||
"Voici l’espace de configuration de votre salon vocal. "
|
||||
"Utilisez les réactions ci-dessous — seul le propriétaire peut réagir."
|
||||
),
|
||||
color=discord.Color.blurple()
|
||||
)
|
||||
members_count = len(voice_channel.members)
|
||||
user_limit = voice_channel.user_limit or 0
|
||||
limit_text = f"{user_limit} max" if user_limit else "Illimitée"
|
||||
members_text = f"{members_count} / {user_limit}" if user_limit else str(members_count)
|
||||
bitrate_kbps = (voice_channel.bitrate or 0) // 1000
|
||||
|
||||
embed.add_field(name="Propriétaire", value=owner.mention, inline=True)
|
||||
embed.add_field(name="Statut du salon", value=_status_display(access_mode), inline=True)
|
||||
embed.add_field(name="Nom du salon", value=voice_channel.name, inline=True)
|
||||
embed.add_field(name="Membres", value=members_text, inline=True)
|
||||
embed.add_field(name="Limite", value=limit_text, inline=True)
|
||||
embed.add_field(name="Bitrate", value=f"{bitrate_kbps} kbps", inline=True)
|
||||
embed.add_field(name="Accès", value="🔓 Ouvert · 🔒 Fermé · 🛡️ Privé", inline=False)
|
||||
embed.add_field(name="Listes", value="✅ Liste blanche · 🚫 Liste noire", inline=False)
|
||||
embed.add_field(name="Actions", value="🧹 Purge · 👑 Propriété · 🎤 Micro · 📹 Vidéo · 📝 Statut", inline=False)
|
||||
return embed
|
||||
|
||||
|
||||
def _room_key(guild_id: int, owner_id: int) -> tuple[int, int]:
|
||||
return (guild_id, owner_id)
|
||||
|
||||
|
||||
def _get_room(guild_id: int, owner_id: int) -> Optional[dict]:
|
||||
return _rooms.get(_room_key(guild_id, owner_id))
|
||||
|
||||
|
||||
def _set_room(guild_id: int, owner_id: int, data: dict):
|
||||
_rooms[_room_key(guild_id, owner_id)] = data
|
||||
mid = data.get("control_message_id")
|
||||
if mid:
|
||||
_control_message_ids[mid] = (guild_id, owner_id)
|
||||
|
||||
|
||||
def _del_room(guild_id: int, owner_id: int):
|
||||
data = _rooms.pop(_room_key(guild_id, owner_id), None)
|
||||
if data and data.get("control_message_id"):
|
||||
_control_message_ids.pop(data["control_message_id"], None)
|
||||
|
||||
|
||||
def _find_room_by_channel(guild_id: int, channel_id: int) -> Optional[tuple[int, dict]]:
|
||||
for (gid, oid), data in _rooms.items():
|
||||
if gid == guild_id and data.get("voice_channel_id") == channel_id:
|
||||
return (oid, data)
|
||||
return None
|
||||
|
||||
|
||||
def _find_room_by_message(message_id: int) -> Optional[tuple[int, int, dict]]:
|
||||
key = _control_message_ids.get(message_id)
|
||||
if not key:
|
||||
return None
|
||||
guild_id, owner_id = key
|
||||
data = _get_room(guild_id, owner_id)
|
||||
if not data:
|
||||
_control_message_ids.pop(message_id, None)
|
||||
return None
|
||||
return (guild_id, owner_id, data)
|
||||
|
||||
|
||||
async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist: set, blacklist: set):
|
||||
guild = channel.guild
|
||||
everyone = guild.default_role
|
||||
overwrites = {}
|
||||
everyone_ow = discord.PermissionOverwrite()
|
||||
if mode == "open":
|
||||
everyone_ow.connect = True
|
||||
everyone_ow.view_channel = True
|
||||
for uid in blacklist:
|
||||
m = guild.get_member(uid)
|
||||
if m:
|
||||
overwrites[m] = discord.PermissionOverwrite(connect=False, view_channel=True)
|
||||
elif mode == "closed":
|
||||
everyone_ow.connect = False
|
||||
everyone_ow.view_channel = True
|
||||
for uid in whitelist:
|
||||
m = guild.get_member(uid)
|
||||
if m:
|
||||
overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True)
|
||||
elif mode == "private":
|
||||
everyone_ow.connect = False
|
||||
everyone_ow.view_channel = False
|
||||
for uid in whitelist:
|
||||
m = guild.get_member(uid)
|
||||
if m:
|
||||
overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True)
|
||||
overwrites[everyone] = everyone_ow
|
||||
await channel.edit(overwrites=overwrites)
|
||||
|
||||
|
||||
async def _handle_reaction_action(bot: discord.Client, guild_id: int, owner_id: int, action: str, channel):
|
||||
"""channel = salon vocal (partie texte / onglet Discussion)."""
|
||||
room = _get_room(guild_id, owner_id)
|
||||
if not room:
|
||||
await channel.send("Ce salon n’existe plus.")
|
||||
return
|
||||
voice_channel = bot.get_channel(room["voice_channel_id"])
|
||||
if not voice_channel or not isinstance(voice_channel, discord.VoiceChannel):
|
||||
await channel.send("Salon vocal introuvable.")
|
||||
return
|
||||
|
||||
if action in ("open", "closed", "private"):
|
||||
room["access_mode"] = action
|
||||
await _apply_access_mode(voice_channel, action, room.get("whitelist", set()), room.get("blacklist", set()))
|
||||
# Mettre à jour le cadenas dans le nom du channel
|
||||
try:
|
||||
base_name = voice_channel.name.rstrip(" 🔓🔒")
|
||||
new_name = f"{base_name} {_status_emoji(action)}"
|
||||
await voice_channel.edit(name=new_name)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
await channel.send(f"Accès du salon défini sur **{action}**.")
|
||||
# Mettre à jour uniquement le statut (cadenas) dans le message de config
|
||||
control_message_id = room.get("control_message_id")
|
||||
if control_message_id:
|
||||
try:
|
||||
msg = await channel.fetch_message(control_message_id)
|
||||
if msg.embeds:
|
||||
embed = msg.embeds[0].copy()
|
||||
for i, f in enumerate(embed.fields):
|
||||
if f.name == "Statut du salon":
|
||||
embed.set_field_at(i, name="Statut du salon", value=_status_display(action), inline=f.inline)
|
||||
break
|
||||
else:
|
||||
embed.add_field(name="Statut du salon", value=_status_display(action), inline=False)
|
||||
await msg.edit(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
elif action == "whitelist":
|
||||
await channel.send("Liste blanche : mentionnez un membre pour l’ajouter/retirer.")
|
||||
|
||||
elif action == "blacklist":
|
||||
await channel.send("Liste noire : mentionnez un membre pour l’ajouter/retirer.")
|
||||
|
||||
elif action == "purge":
|
||||
whitelist = room.get("whitelist", set())
|
||||
kicked = 0
|
||||
for member in list(voice_channel.members):
|
||||
if member.id == owner_id or member.id in whitelist:
|
||||
continue
|
||||
try:
|
||||
await member.move_to(None)
|
||||
kicked += 1
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
await channel.send(f"Purge effectuée : {kicked} membre(s) déconnecté(s).")
|
||||
|
||||
elif action == "transfer":
|
||||
await channel.send("Transférer le salon : mentionnez le membre à qui donner la propriété.")
|
||||
|
||||
elif action in ("speak", "stream"):
|
||||
everyone = voice_channel.guild.default_role
|
||||
overwrites = dict(voice_channel.overwrites)
|
||||
ow = overwrites.get(everyone) or discord.PermissionOverwrite()
|
||||
current = getattr(ow, action)
|
||||
setattr(ow, action, not current if current is not None else False)
|
||||
overwrites[everyone] = ow
|
||||
await voice_channel.edit(overwrites=overwrites)
|
||||
label = "Micro" if action == "speak" else "Vidéo"
|
||||
await channel.send(f"{label} : {'autorisé' if getattr(ow, action) else 'désactivé'} pour tous.")
|
||||
|
||||
elif action == "status":
|
||||
status_text = _status_display(room.get("access_mode", "open"))
|
||||
await channel.send(f"Statut du salon : {status_text}\nRépondez avec le nouveau nom du salon pour le modifier.")
|
||||
|
||||
|
||||
async def send_control_panel(bot: discord.Client, guild_id: int, owner: Member, voice_channel: discord.VoiceChannel) -> Optional[int]:
|
||||
"""Envoie le message de config avec réactions dans la partie texte du salon vocal (onglet Discussion). Seul le proprio peut réagir. Retourne l’id du message."""
|
||||
embed = _build_control_embed(owner, voice_channel, "open")
|
||||
|
||||
try:
|
||||
# Message dans la partie texte du vocal (onglet Discussion à droite)
|
||||
msg = await voice_channel.send(embed=embed)
|
||||
for emoji, _action, _label in REACTIONS:
|
||||
await msg.add_reaction(emoji)
|
||||
return msg.id
|
||||
except discord.HTTPException as e:
|
||||
logging.error(f"Impossible d’envoyer le panneau Auto Room dans le vocal : {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member, before: VoiceState, after: VoiceState):
|
||||
config = ConfigurationHelper()
|
||||
if not config.getValue("auto_rooms_enable"):
|
||||
return
|
||||
trigger_channel_id = config.getIntValue("auto_rooms_channel_id")
|
||||
if not trigger_channel_id:
|
||||
return
|
||||
|
||||
guild = member.guild
|
||||
|
||||
if after.channel and after.channel.id == trigger_channel_id:
|
||||
category = after.channel.category
|
||||
# Nom du salon avec statut (cadenas) à la création
|
||||
channel_name = f"Salon de {member.display_name} {_status_emoji('open')}"
|
||||
try:
|
||||
new_channel = await guild.create_voice_channel(
|
||||
name=channel_name,
|
||||
category=category,
|
||||
reason="Auto room"
|
||||
)
|
||||
await member.move_to(new_channel)
|
||||
control_message_id = await send_control_panel(bot, guild.id, member, new_channel)
|
||||
_set_room(guild.id, member.id, {
|
||||
"guild_id": guild.id,
|
||||
"voice_channel_id": new_channel.id,
|
||||
"control_message_id": control_message_id,
|
||||
"owner_id": member.id,
|
||||
"whitelist": set(),
|
||||
"blacklist": set(),
|
||||
"access_mode": "open",
|
||||
})
|
||||
logging.info(f"Auto room créé : {new_channel.name} pour {member.display_name}")
|
||||
except discord.HTTPException as e:
|
||||
logging.error(f"Erreur création auto room : {e}")
|
||||
|
||||
if before.channel and before.channel.id != trigger_channel_id:
|
||||
result = _find_room_by_channel(guild.id, before.channel.id)
|
||||
if result:
|
||||
owner_id, room = result
|
||||
remaining = [m for m in before.channel.members if m.id != member.id]
|
||||
if member.id == owner_id:
|
||||
_del_room(guild.id, owner_id)
|
||||
try:
|
||||
await before.channel.delete(reason="Propriétaire parti (auto room)")
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
elif len(remaining) == 0:
|
||||
_del_room(guild.id, owner_id)
|
||||
try:
|
||||
await before.channel.delete(reason="Auto room vide")
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
|
||||
async def on_raw_reaction_add_auto_rooms(bot: discord.Client, payload: discord.RawReactionActionEvent):
|
||||
"""Seul le propriétaire peut réagir ; on retire la réaction des autres."""
|
||||
if payload.user_id == bot.user.id:
|
||||
return
|
||||
if not ConfigurationHelper().getValue("auto_rooms_enable"):
|
||||
return
|
||||
room_info = _find_room_by_message(payload.message_id)
|
||||
if not room_info:
|
||||
return
|
||||
guild_id, owner_id, room = room_info
|
||||
if payload.user_id != owner_id:
|
||||
try:
|
||||
channel = bot.get_channel(payload.channel_id)
|
||||
if channel:
|
||||
msg = await channel.fetch_message(payload.message_id)
|
||||
user = payload.member or await bot.fetch_user(payload.user_id)
|
||||
await msg.remove_reaction(payload.emoji, user)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
return
|
||||
|
||||
emoji_str = str(payload.emoji)
|
||||
action = None
|
||||
for e, a, _ in REACTIONS:
|
||||
if e == emoji_str:
|
||||
action = a
|
||||
break
|
||||
if not action:
|
||||
return
|
||||
|
||||
# Canal = salon vocal (le message est dans la partie texte du vocal)
|
||||
channel = bot.get_channel(payload.channel_id)
|
||||
if not channel or not hasattr(channel, "send"):
|
||||
return
|
||||
|
||||
await _handle_reaction_action(bot, guild_id, owner_id, action, channel)
|
||||
|
||||
try:
|
||||
msg = await channel.fetch_message(payload.message_id)
|
||||
user = payload.member or await bot.fetch_user(payload.user_id)
|
||||
await msg.remove_reaction(payload.emoji, user)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
@@ -0,0 +1,232 @@
|
||||
# FreeLoot Discord : notifications depuis le feed LootScraper (jeux gratuits Epic, Amazon Prime, GOG, etc.)
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from discord import Client
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import FreeLootEntry
|
||||
from freeloot_feed import (
|
||||
SOURCES,
|
||||
fetch_feed,
|
||||
source_key_from_entry,
|
||||
game_name_from_title,
|
||||
extract_image_from_content,
|
||||
extract_description_from_content,
|
||||
extract_valid_to_from_content,
|
||||
extract_recommended_price_from_content,
|
||||
extract_genres_from_content,
|
||||
extract_rating_from_content,
|
||||
)
|
||||
|
||||
|
||||
def _get_mention_content() -> str:
|
||||
"""Construit le contenu du message (mentions) depuis la config."""
|
||||
raw = ConfigurationHelper().getValue("freeloot_mention")
|
||||
if not raw or not str(raw).strip():
|
||||
return ""
|
||||
parts = []
|
||||
for s in str(raw).strip().split(","):
|
||||
s = s.strip()
|
||||
if s == "everyone":
|
||||
parts.append("@everyone")
|
||||
elif s == "here":
|
||||
parts.append("@here")
|
||||
elif s.isdigit():
|
||||
parts.append(f"<@&{s}>")
|
||||
return " ".join(parts) if parts else ""
|
||||
|
||||
|
||||
def _is_enabled_source(source_key: str) -> bool:
|
||||
"""Vérifie si cette source est activée dans la config (freeloot_sources)."""
|
||||
raw = ConfigurationHelper().getValue("freeloot_sources")
|
||||
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
|
||||
return True
|
||||
enabled = [s.strip() for s in str(raw).split(",") if s.strip()]
|
||||
return source_key in enabled if enabled else True
|
||||
|
||||
|
||||
def _store_label_for_title(source_key: str) -> str:
|
||||
"""Libellé court pour le titre style DraftBot (ex: 'l'Epic Games Store')."""
|
||||
labels = {
|
||||
"epic_pc": "l'Epic Games Store",
|
||||
"epic_android": "l'Epic Games Store (Android)",
|
||||
"epic_ios": "l'Epic Games Store (iOS)",
|
||||
"amazon_prime": "Amazon Prime Gaming",
|
||||
"gog": "GOG",
|
||||
"google_play": "Google Play",
|
||||
"apple_app_store": "l'App Store",
|
||||
}
|
||||
return labels.get(source_key, "la boutique")
|
||||
|
||||
|
||||
# Logo (thumbnail) de chaque boutique pour l'embed Discord (affiché en haut à droite)
|
||||
SOURCE_LOGO_URLS = {
|
||||
"epic_pc": "https://store.epicgames.com/favicon.ico",
|
||||
"epic_android": "https://store.epicgames.com/favicon.ico",
|
||||
"epic_ios": "https://store.epicgames.com/favicon.ico",
|
||||
"amazon_prime": "https://gaming.amazon.com/favicon.ico",
|
||||
"gog": "https://www.gog.com/favicon.ico",
|
||||
"google_play": "https://play.google.com/favicon.ico",
|
||||
"apple_app_store": "https://www.apple.com/favicon.ico",
|
||||
}
|
||||
|
||||
|
||||
def _build_embed(entry: dict, source_key: str):
|
||||
import discord
|
||||
game_name = game_name_from_title(entry["title"])
|
||||
source_label = next((s[1] for s in SOURCES if s[0] == source_key), source_key)
|
||||
link = entry.get("link") or ""
|
||||
content_raw = entry.get("content") or ""
|
||||
img_url = extract_image_from_content(content_raw)
|
||||
description = extract_description_from_content(content_raw, max_len=350)
|
||||
valid_to = extract_valid_to_from_content(content_raw)
|
||||
store_title = _store_label_for_title(source_key)
|
||||
# Couleur barre gauche style DraftBot (orange-rouge)
|
||||
color = 0xE67E22
|
||||
title = f"{game_name} gratuit sur {store_title} !"
|
||||
embed = discord.Embed(
|
||||
title=title,
|
||||
url=link if link.startswith("http") else None,
|
||||
color=color,
|
||||
)
|
||||
if description:
|
||||
embed.description = description
|
||||
# Prix / gratuit / validité (Discord : pas de couleur dans le texte, seulement **gras** / markdown)
|
||||
value_parts = ["**Gratuit**"]
|
||||
if valid_to:
|
||||
try:
|
||||
from datetime import datetime
|
||||
end = datetime.fromisoformat(valid_to.replace("Z", "+00:00"))
|
||||
value_parts.append(f"jusqu'au {end.strftime('%d/%m/%Y')}")
|
||||
except Exception:
|
||||
value_parts.append(f"jusqu'au {valid_to[:10]}")
|
||||
embed.add_field(
|
||||
name="Prix",
|
||||
value=" • ".join(value_parts),
|
||||
inline=False,
|
||||
)
|
||||
recommended_price = extract_recommended_price_from_content(content_raw)
|
||||
if recommended_price:
|
||||
embed.add_field(name="Prix recommandé", value=recommended_price, inline=True)
|
||||
genres = extract_genres_from_content(content_raw)
|
||||
if genres:
|
||||
embed.add_field(name="Genres", value=genres, inline=True)
|
||||
rating = extract_rating_from_content(content_raw)
|
||||
if rating:
|
||||
embed.add_field(name="Ratings", value=rating, inline=True)
|
||||
if link and link.startswith("http"):
|
||||
embed.add_field(
|
||||
name="\u200b",
|
||||
value=f"[Ouvrir dans la boutique !]({link})",
|
||||
inline=False,
|
||||
)
|
||||
# Thumbnail (logo de la boutique en haut à droite)
|
||||
logo_url = SOURCE_LOGO_URLS.get(source_key)
|
||||
if logo_url and logo_url.startswith("http"):
|
||||
embed.set_thumbnail(url=logo_url)
|
||||
# Image principale (style DraftBot)
|
||||
if img_url and img_url.startswith("http"):
|
||||
embed.set_image(url=img_url)
|
||||
embed.set_footer(text="MamieHenriette • FreeLoot")
|
||||
return embed
|
||||
|
||||
|
||||
_freeloot_first_check = True
|
||||
|
||||
async def checkFreeLootAndNotify(bot: Client):
|
||||
global _freeloot_first_check
|
||||
helper = ConfigurationHelper()
|
||||
if not helper.getValue("freeloot_enable"):
|
||||
return
|
||||
channel_id = helper.getIntValue("freeloot_channel_id")
|
||||
if not channel_id:
|
||||
return
|
||||
channel = bot.get_channel(channel_id)
|
||||
if not channel:
|
||||
logging.warning("FreeLoot: canal Discord introuvable")
|
||||
return
|
||||
entries = fetch_feed()
|
||||
if not entries:
|
||||
return
|
||||
|
||||
# Au premier check après le démarrage, on synchronise sans notifier
|
||||
if _freeloot_first_check:
|
||||
logging.info("FreeLoot: première vérification, synchronisation sans notification")
|
||||
for entry in entries:
|
||||
entry_id = entry["id"]
|
||||
if not FreeLootEntry.query.get(entry_id):
|
||||
source_key = source_key_from_entry(entry["title"], entry["link"])
|
||||
if source_key and _is_enabled_source(source_key):
|
||||
try:
|
||||
db.session.add(FreeLootEntry(entry_id=entry_id))
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
logging.error(f"FreeLoot: erreur de synchronisation pour {entry_id}: {e}")
|
||||
db.session.rollback()
|
||||
_freeloot_first_check = False
|
||||
return
|
||||
|
||||
# Vérifications suivantes : notification normale
|
||||
for entry in entries:
|
||||
entry_id = entry["id"]
|
||||
if FreeLootEntry.query.get(entry_id):
|
||||
continue
|
||||
source_key = source_key_from_entry(entry["title"], entry["link"])
|
||||
if not source_key or not _is_enabled_source(source_key):
|
||||
continue
|
||||
try:
|
||||
embed = _build_embed(entry, source_key)
|
||||
content = _get_mention_content()
|
||||
await channel.send(content=content or None, embed=embed)
|
||||
db.session.add(FreeLootEntry(entry_id=entry_id))
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
logging.error(f"FreeLoot: envoi Discord échoué pour {entry_id}: {e}")
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
async def _send_entry_to_discord_async(bot: Client, entry_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Envoie une entrée FreeLoot sur Discord (appel manuel). Retourne (succès, message).
|
||||
"""
|
||||
channel_id = ConfigurationHelper().getIntValue("freeloot_channel_id")
|
||||
if not channel_id:
|
||||
return (False, "Aucun canal Discord configuré pour FreeLoot.")
|
||||
channel = bot.get_channel(channel_id)
|
||||
if not channel:
|
||||
return (False, "Canal Discord introuvable.")
|
||||
entries = fetch_feed()
|
||||
if not entries:
|
||||
return (False, "Impossible de charger le flux.")
|
||||
entry = next((e for e in entries if e.get("id") == entry_id), None)
|
||||
if not entry:
|
||||
return (False, "Entrée introuvable dans le flux.")
|
||||
source_key = source_key_from_entry(entry["title"], entry["link"])
|
||||
if not source_key:
|
||||
return (False, "Source non reconnue pour cette entrée.")
|
||||
try:
|
||||
embed = _build_embed(entry, source_key)
|
||||
content = _get_mention_content()
|
||||
await channel.send(content=content or None, embed=embed)
|
||||
if not FreeLootEntry.query.get(entry_id):
|
||||
db.session.add(FreeLootEntry(entry_id=entry_id))
|
||||
db.session.commit()
|
||||
return (True, "Annonce envoyée sur Discord.")
|
||||
except Exception as e:
|
||||
logging.error(f"FreeLoot: envoi manuel échoué pour {entry_id}: {e}")
|
||||
db.session.rollback()
|
||||
return (False, str(e))
|
||||
|
||||
|
||||
def send_entry_to_discord_sync(bot: Client, entry_id: str) -> tuple[bool, str]:
|
||||
"""Appel synchrone pour envoyer une entrée sur Discord (depuis la webapp)."""
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
_send_entry_to_discord_async(bot, entry_id),
|
||||
bot.loop,
|
||||
)
|
||||
return future.result(timeout=15)
|
||||
except Exception as e:
|
||||
logging.error(f"FreeLoot: send_entry_to_discord_sync: {e}")
|
||||
return (False, str(e))
|
||||
@@ -8,6 +8,8 @@ from database.helpers import ConfigurationHelper
|
||||
from database.models import GameBundle
|
||||
from discord import Client
|
||||
|
||||
_humblebundle_first_check = True
|
||||
|
||||
|
||||
def _isEnable():
|
||||
helper = ConfigurationHelper()
|
||||
@@ -40,10 +42,22 @@ def _formatMessage(bundle):
|
||||
return message
|
||||
|
||||
async def checkHumbleBundleAndNotify(bot: Client):
|
||||
global _humblebundle_first_check
|
||||
if _isEnable() :
|
||||
try :
|
||||
bundles = _callGithub()
|
||||
bundle = _findFirstNotNotified(bundles)
|
||||
|
||||
# Premier check : synchronisation sans notification
|
||||
if _humblebundle_first_check:
|
||||
if bundle != None:
|
||||
logging.info(f'HumbleBundle: première vérification, synchronisation sans notification pour {bundle["name"]}')
|
||||
db.session.add(GameBundle(url=bundle['url'], name=bundle['name'], json = json.dumps(bundle)))
|
||||
db.session.commit()
|
||||
_humblebundle_first_check = False
|
||||
return
|
||||
|
||||
# Vérifications normales ensuite
|
||||
if bundle != None :
|
||||
message = _formatMessage(bundle)
|
||||
await bot.get_channel(ConfigurationHelper().getIntValue('humble_bundle_channel')).send(message)
|
||||
|
||||
+19
-2
@@ -10,23 +10,31 @@ from webapp import webapp
|
||||
logger = logging.getLogger('youtube-notification')
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
_youtube_first_check = True
|
||||
|
||||
|
||||
async def checkYouTubeVideos():
|
||||
global _youtube_first_check
|
||||
with webapp.app_context():
|
||||
try:
|
||||
notifications: list[YouTubeNotification] = YouTubeNotification.query.filter_by(enable=True).all()
|
||||
|
||||
for notification in notifications:
|
||||
try:
|
||||
await _checkChannelVideos(notification)
|
||||
await _checkChannelVideos(notification, is_first_check=_youtube_first_check)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la vérification de la chaîne {notification.channel_id}: {e}")
|
||||
continue
|
||||
|
||||
# Après la première vérification complète, on désactive le flag
|
||||
if _youtube_first_check:
|
||||
_youtube_first_check = False
|
||||
logger.info("YouTube: première vérification terminée, notifications activées")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la vérification YouTube: {e}")
|
||||
|
||||
|
||||
async def _checkChannelVideos(notification: YouTubeNotification):
|
||||
async def _checkChannelVideos(notification: YouTubeNotification, is_first_check: bool = False):
|
||||
try:
|
||||
channel_id = notification.channel_id
|
||||
|
||||
@@ -109,6 +117,15 @@ async def _checkChannelVideos(notification: YouTubeNotification):
|
||||
if videos:
|
||||
latest_video_id, latest_video = videos[0]
|
||||
|
||||
# Si c'est la première vérification après démarrage, on synchronise sans notifier
|
||||
if is_first_check:
|
||||
if not notification.last_video_id or notification.last_video_id != latest_video_id:
|
||||
logger.info(f"YouTube: synchronisation initiale pour {channel_id}, dernière vidéo: {latest_video_id}")
|
||||
notification.last_video_id = latest_video_id
|
||||
db.session.commit()
|
||||
return
|
||||
|
||||
# Vérifications normales ensuite
|
||||
if not notification.last_video_id:
|
||||
notification.last_video_id = latest_video_id
|
||||
db.session.commit()
|
||||
|
||||
Reference in New Issue
Block a user