32 changed files with 1406 additions and 4655 deletions
+1 -18
View File
@@ -166,23 +166,6 @@ def _doAddColumnMigrations(cursor: Cursor):
except Exception as e:
logging.warning(f"Seed twitch_event_notification {ev}: {e}")
# Colonnes supplémentaires pour patreon_post (historique + statut notification)
if _tableExists('patreon_post', cursor):
patreon_columns = [
('title', 'VARCHAR(512)'),
('link', 'VARCHAR(1024)'),
('description', 'TEXT'),
('published_at', 'VARCHAR(64)'),
('notified', 'BOOLEAN NOT NULL DEFAULT 0'),
]
for col_name, col_type in patreon_columns:
if not _tableHaveColumn('patreon_post', col_name, cursor):
try:
cursor.execute(f'ALTER TABLE patreon_post ADD COLUMN {col_name} {col_type}')
logging.info(f"Colonne {col_name} ajoutée à patreon_post")
except Exception as e:
logging.warning(f"Colonne patreon_post.{col_name}: {e}")
# Table webapp_user (auth)
if not _tableExists('webapp_user', cursor):
try:
@@ -227,6 +210,7 @@ def _doAddColumnMigrations(cursor: Cursor):
except Exception as e:
logging.warning(f"Table webapp_page_permission: {e}")
def _doSeedAuth(cursor: Cursor):
"""Seed rôles par défaut et permissions des pages si vides."""
from database.models import ROLE_ORDER
@@ -267,7 +251,6 @@ def _doSeedAuth(cursor: Cursor):
("youtube", 1, 2),
("protondb", 1, 2),
("freeloot", 1, 2),
("patreon", 1, 2),
("moderation", 1, 2),
("users", 5, 5),
("settings", 5, 5),
+1 -46
View File
@@ -60,7 +60,7 @@ class WebappUser(db.Model, UserMixin):
class Configuration(db.Model):
key = db.Column(db.String(32), primary_key=True)
value = db.Column(db.Text)
value = db.Column(db.String(512))
class Humeur(db.Model):
id = db.Column(db.Integer, primary_key=True)
@@ -195,34 +195,6 @@ class YouTubeNotification(db.Model):
embed_image = db.Column(db.Boolean, default=True)
class YouTubeVideoHistory(db.Model):
__tablename__ = 'youtube_video_history'
id = db.Column(db.Integer, primary_key=True)
notification_id = db.Column(db.Integer, db.ForeignKey('youtube_notification.id'), nullable=False)
video_id = db.Column(db.String(128), nullable=False)
title = db.Column(db.String(512))
url = db.Column(db.String(512))
channel_name = db.Column(db.String(256))
thumbnail = db.Column(db.String(512))
published_at = db.Column(db.String(64))
is_short = db.Column(db.Boolean, default=False)
notified = db.Column(db.Boolean, default=False)
detected_at = db.Column(db.DateTime, default=datetime.utcnow)
class AutoRoom(db.Model):
"""État persistant des salons vocaux temporaires."""
__tablename__ = 'auto_room'
guild_id = db.Column(db.String(32), primary_key=True)
voice_channel_id = db.Column(db.String(32), primary_key=True)
owner_id = db.Column(db.String(32), nullable=False)
control_message_id = db.Column(db.String(32))
access_mode = db.Column(db.String(16), nullable=False, default='open')
whitelist = db.Column(db.Text, nullable=False, default='[]')
blacklist = db.Column(db.Text, nullable=False, default='[]')
managed_member_ids = db.Column(db.Text, nullable=False, default='[]')
class FreeLootEntry(db.Model):
__tablename__ = 'freeloot_entry'
entry_id = db.Column(db.String(256), primary_key=True)
@@ -255,20 +227,3 @@ class TwitchBannedWord(db.Model):
timeout_duration = db.Column(db.Integer, default=60) # durée du timeout en secondes
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class PatreonPost(db.Model):
__tablename__ = 'patreon_post'
guid = db.Column(db.String(512), primary_key=True)
title = db.Column(db.String(512))
link = db.Column(db.String(1024))
description = db.Column(db.Text)
published_at = db.Column(db.String(64))
notified = db.Column(db.Boolean, default=False)
class ModShoutboxMessage(db.Model):
__tablename__ = 'mod_shoutbox_message'
id = db.Column(db.Integer, primary_key=True)
author = db.Column(db.String(64), nullable=False)
message = db.Column(db.String(500), nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+1 -44
View File
@@ -1,7 +1,7 @@
CREATE TABLE IF NOT EXISTS `configuration` (
`key` VARCHAR(32) PRIMARY KEY,
`value` TEXT NOT NULL
`value` VARCHAR(512) NOT NULL
);
CREATE TABLE IF NOT EXISTS `game_alias` (
@@ -178,33 +178,6 @@ CREATE TABLE IF NOT EXISTS `webapp_page_permission` (
description VARCHAR(256) NULL
);
CREATE TABLE IF NOT EXISTS `youtube_video_history` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`notification_id` INTEGER NOT NULL,
`video_id` VARCHAR(128) NOT NULL,
`title` VARCHAR(512),
`url` VARCHAR(512),
`channel_name` VARCHAR(256),
`thumbnail` VARCHAR(512),
`published_at` VARCHAR(64),
`is_short` BOOLEAN NOT NULL DEFAULT FALSE,
`notified` BOOLEAN NOT NULL DEFAULT FALSE,
`detected_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`notification_id`) REFERENCES `youtube_notification`(`id`) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS `auto_room` (
`guild_id` VARCHAR(32) NOT NULL,
`voice_channel_id` VARCHAR(32) NOT NULL UNIQUE,
`owner_id` VARCHAR(32) NOT NULL,
`control_message_id` VARCHAR(32),
`access_mode` VARCHAR(16) NOT NULL DEFAULT 'open',
`whitelist` TEXT NOT NULL DEFAULT '[]',
`blacklist` TEXT NOT NULL DEFAULT '[]',
`managed_member_ids` TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY (`guild_id`, `voice_channel_id`)
);
CREATE TABLE IF NOT EXISTS `freeloot_entry` (
entry_id VARCHAR(256) PRIMARY KEY
);
@@ -225,19 +198,3 @@ CREATE TABLE IF NOT EXISTS `twitch_event_notification` (
embed_thumbnail BOOLEAN NOT NULL DEFAULT TRUE,
last_clip_id VARCHAR(128) NULL
);
CREATE TABLE IF NOT EXISTS `patreon_post` (
guid VARCHAR(512) PRIMARY KEY,
title VARCHAR(512),
link VARCHAR(1024),
description TEXT,
published_at VARCHAR(64),
notified BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS `mod_shoutbox_message` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`author` VARCHAR(64) NOT NULL,
`message` VARCHAR(500) NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+119 -53
View File
@@ -14,58 +14,31 @@ from discordbot.moderation import (
handle_warning_command,
handle_remove_warning_command,
handle_list_warnings_command,
handle_ban_command,
handle_kick_command,
handle_unban_command,
handle_inspect_command,
handle_ban_list_command,
handle_staff_help_command,
handle_timeout_command,
handle_say_command,
handle_transfer_command,
transfer_message_context_menu,
moderation_slash_ban,
moderation_slash_kick,
moderation_slash_timeout,
moderation_ctx_ban_author,
moderation_ctx_kick_author,
moderation_ctx_timeout_author,
moderation_slash_warn,
moderation_slash_inspect,
moderation_ctx_warn_author,
moderation_slash_say,
transfer_message_context_menu
)
from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
from discordbot.rules_ack import assign_rules_arrival_on_join, register_persistent_rules_view
from discordbot.patreon import checkPatreonPosts
from discordbot.youtube import checkYouTubeVideos
from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms, on_message_auto_rooms, cleanup_orphaned_auto_rooms, restore_auto_rooms
from discordbot.protondb_discord import protondb_slash_command, pdb_slash_command
from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms, on_message_auto_rooms
from protondb import searhProtonDb
class DiscordBot(discord.Client):
def __init__(self, *, intents: discord.Intents):
super().__init__(intents=intents)
self.tree = app_commands.CommandTree(self)
self.synced = False
self.background_tasks_started = False
async def setup_hook(self):
for cmd in (
transfer_message_context_menu,
moderation_slash_ban,
moderation_slash_kick,
moderation_slash_timeout,
moderation_ctx_ban_author,
moderation_ctx_kick_author,
moderation_ctx_timeout_author,
moderation_slash_warn,
moderation_slash_inspect,
moderation_ctx_warn_author,
moderation_slash_say,
protondb_slash_command,
pdb_slash_command,
):
self.tree.add_command(cmd)
logging.info("Commandes d'application (transfert, modération, ProtonDB) ajoutées au CommandTree")
register_persistent_rules_view(self)
logging.info("Vue persistante règlement (bouton) enregistrée")
self.tree.add_command(transfer_message_context_menu)
logging.info("Commande contextuelle 'Déplacer le message' ajoutée au CommandTree")
async def on_ready(self):
logging.info(f'Connecté en tant que {self.user} (ID: {self.user.id})')
@@ -97,28 +70,16 @@ class DiscordBot(discord.Client):
for guild in self.guilds:
await updateInviteCache(guild)
await restore_auto_rooms(self)
await cleanup_orphaned_auto_rooms(self)
# on_ready est rappelé après une reconnexion : ne pas démarrer plusieurs
# boucles de surveillance, qui peuvent envoyer des notifications en double.
if not self.background_tasks_started:
self.background_tasks_started = True
self.loop.create_task(self.updateStatus())
self.loop.create_task(self.updateHumbleBundle())
self.loop.create_task(self.updateYouTube())
self.loop.create_task(self.updateFreeLoot())
self.loop.create_task(self.updatePatreon())
async def on_disconnect(self):
webapp.config["BOT_STATUS"]["discord_connected"] = False
async def updateStatus(self):
while not self.is_closed():
bot_status = webapp.config.get("BOT_STATUS", {})
if bot_status.get("twitch_is_live") or bot_status.get("discord_streaming_activity"):
await asyncio.sleep(60)
continue
humeurs = Humeur.query.all()
if len(humeurs)>0 :
humeur = random.choice(humeurs)
@@ -142,11 +103,6 @@ class DiscordBot(discord.Client):
await checkFreeLootAndNotify(self)
await asyncio.sleep(30*60)
async def updatePatreon(self):
while not self.is_closed():
await checkPatreonPosts(self)
await asyncio.sleep(10*60)
def getAllTextChannel(self) -> list[TextChannel]:
channels = []
for channel in self.get_all_channels():
@@ -208,6 +164,10 @@ async def on_message(message: Message):
await handle_warning_command(message, bot)
return
if command_name in ['!to', '!timeout']:
await handle_timeout_command(message, bot)
return
if command_name in ['!delaverto', '!removewarn', '!unwarn']:
await handle_remove_warning_command(message, bot)
return
@@ -217,6 +177,10 @@ async def on_message(message: Message):
return
if ConfigurationHelper().getValue('moderation_ban_enable'):
if command_name == '!ban':
await handle_ban_command(message, bot)
return
if command_name == '!unban':
await handle_unban_command(message, bot)
return
@@ -224,6 +188,11 @@ async def on_message(message: Message):
await handle_ban_list_command(message, bot)
return
if ConfigurationHelper().getValue('moderation_kick_enable'):
if command_name == '!kick':
await handle_kick_command(message, bot)
return
if ConfigurationHelper().getValue('moderation_enable'):
if command_name == '!inspect':
await handle_inspect_command(message, bot)
@@ -249,6 +218,103 @@ async def on_message(message: Message):
except Exception as e:
logging.error(f'Échec de l\'exécution de la commande Discord : {e}')
if (ConfigurationHelper().getValue('proton_db_enable_enable') and (message.content.startswith('!protondb') or message.content.startswith('!pdb'))):
if (message.content.find('<@')>0) :
mention = message.content[message.content.find('<@'):]
else :
mention = message.author.mention
name = message.content
if name.startswith('!protondb'):
name = name.replace('!protondb', '', 1)
elif name.startswith('!pdb'):
name = name.replace('!pdb', '', 1)
name = name.replace(f'{mention}', '').strip();
if not name or len(name) == 0:
try:
await message.delete()
delete_time = ConfigurationHelper().getIntValue('proton_db_delete_time') or 10
help_msg = await message.channel.send(
f"{mention} ⚠️ Utilisation: `!pdb nom du jeu` ou `!protondb nom du jeu`\n"
f"Exemple: `!pdb Elden Ring`",
suppress_embeds=True
)
await asyncio.sleep(delete_time)
await help_msg.delete()
except Exception as e:
logging.error(f"Échec de la gestion du message d'aide ProtonDB : {e}")
return
try:
searching_msg = await message.channel.send(f"🔍 Recherche en cours pour **{name}**...")
games = searhProtonDb(name)
await searching_msg.delete()
except:
games = searhProtonDb(name)
if (len(games)==0) :
msg = f'{mention} Je n\'ai pas trouvé de jeux correspondant à **{name}**. Es-tu sûr que le jeu est disponible sur Steam ?'
try:
await message.channel.send(msg, suppress_embeds=True)
except Exception as e:
logging.error(f"Échec de l'envoi du message ProtonDB : {e}")
return
total_games = len(games)
tier_colors = {'platinum': '🟣', 'gold': '🟡', 'silver': '', 'bronze': '🟤', 'borked': '🔴'}
content = ""
max_games = 15
for count, game in enumerate(games[:max_games]):
g_name = str(game.get('name'))
g_id = str(game.get('id'))
tier = str(game.get('tier') or 'N/A').lower()
tier_icon = tier_colors.get(tier, '')
new_entry = f"**[{g_name}](<https://www.protondb.com/app/{g_id}>)**\n{tier_icon} Classé **{tier.capitalize()}**"
ac_status = game.get('anticheat_status')
if ac_status:
status_lower = str(ac_status).lower()
ac_map = {
'supported': ('', 'Supporté'),
'running': ('⚠️', 'Fonctionne'),
'broken': ('', 'Cassé'),
'denied': ('🚫', 'Refusé'),
'planned': ('📅', 'Planifié')
}
ac_emoji, ac_label = ac_map.get(status_lower, ('', str(ac_status)))
acs = game.get('anticheats') or []
ac_list = ', '.join([str(ac) for ac in acs if ac])
new_entry += f" • [Anti-cheat {ac_emoji} {ac_label}"
if ac_list:
new_entry += f" ({ac_list})"
new_entry += f"](<https://areweanticheatyet.com/game/{g_id}>)"
new_entry += "\n\n"
# Vérifier la limite avant d'ajouter
if len(content) + len(new_entry) > 3900:
rest = len(games) - count
content += f"*... et {rest} autre{'s' if rest > 1 else ''} jeu{'x' if rest > 1 else ''}*"
break
content += new_entry
else:
rest = max(0, len(games) - max_games)
if rest > 0:
content += f"*... et {rest} autre{'s' if rest > 1 else ''} jeu{'x' if rest > 1 else ''}*"
embed = discord.Embed(
title=f"🎮 Résultats ProtonDB - **{total_games} jeu{'x' if total_games > 1 else ''} trouvé{'s' if total_games > 1 else ''}**",
description=content,
color=0x5865F2
)
try :
await message.channel.send(embed=embed)
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)
@@ -259,7 +325,6 @@ async def on_raw_reaction_add(payload: discord.RawReactionActionEvent):
@bot.event
async def on_member_join(member: Member):
await assign_rules_arrival_on_join(bot, member)
await sendWelcomeMessage(bot, member)
@bot.event
@@ -273,3 +338,4 @@ async def on_invite_create(invite):
@bot.event
async def on_invite_delete(invite):
await updateInviteCache(invite.guild)
+29 -203
View File
@@ -1,15 +1,10 @@
# discordbot/auto_rooms.py — Auto rooms : message et réactions dans la partie texte du salon vocal (onglet Discussion)
import logging
import re
import json
from typing import Optional
import discord
from discord import Member, VoiceState
from database import db
from database.helpers import ConfigurationHelper
from database.models import AutoRoom
from webapp import webapp
# (guild_id, owner_id) -> room_data (voice_channel_id, control_message_id, whitelist, blacklist, access_mode)
_rooms: dict[tuple[int, int], dict] = {}
@@ -127,33 +122,7 @@ def _room_key(guild_id: int, owner_id: int) -> tuple[int, int]:
def _get_room(guild_id: int, owner_id: int) -> Optional[dict]:
room = _rooms.get(_room_key(guild_id, owner_id))
if room:
return room
# Le cache peut être perdu après un redémarrage ou une reconnexion : la base
# reste la source de vérité pour éviter de créer une deuxième room au même membre.
with webapp.app_context():
record = AutoRoom.query.filter_by(guild_id=str(guild_id), owner_id=str(owner_id)).first()
if not record:
return None
try:
room = {
"guild_id": guild_id,
"voice_channel_id": int(record.voice_channel_id),
"control_message_id": int(record.control_message_id) if record.control_message_id else None,
"owner_id": owner_id,
"whitelist": set(json.loads(record.whitelist or "[]")),
"blacklist": set(json.loads(record.blacklist or "[]")),
"managed_member_ids": set(json.loads(record.managed_member_ids or "[]")),
"access_mode": record.access_mode or "open",
}
except (TypeError, ValueError):
return None
_rooms[_room_key(guild_id, owner_id)] = room
if room["control_message_id"]:
_control_message_ids[room["control_message_id"]] = (guild_id, owner_id)
return room
return _rooms.get(_room_key(guild_id, owner_id))
def _set_room(guild_id: int, owner_id: int, data: dict):
@@ -161,53 +130,18 @@ def _set_room(guild_id: int, owner_id: int, data: dict):
mid = data.get("control_message_id")
if mid:
_control_message_ids[mid] = (guild_id, owner_id)
_persist_room(guild_id, owner_id, data)
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)
if data:
with webapp.app_context():
AutoRoom.query.filter_by(guild_id=str(guild_id), voice_channel_id=str(data["voice_channel_id"])).delete()
db.session.commit()
def _persist_room(guild_id: int, owner_id: int, data: dict):
"""Sauvegarde l'état nécessaire à la reprise après redémarrage."""
with webapp.app_context():
record = AutoRoom.query.filter_by(
guild_id=str(guild_id), voice_channel_id=str(data["voice_channel_id"])
).first()
if not record:
record = AutoRoom(guild_id=str(guild_id), voice_channel_id=str(data["voice_channel_id"]))
db.session.add(record)
record.owner_id = str(owner_id)
record.control_message_id = str(data["control_message_id"]) if data.get("control_message_id") else None
record.access_mode = data.get("access_mode", "open")
record.whitelist = json.dumps(sorted(data.get("whitelist", set())))
record.blacklist = json.dumps(sorted(data.get("blacklist", set())))
record.managed_member_ids = json.dumps(sorted(data.get("managed_member_ids", set())))
db.session.commit()
def _auto_rooms_config() -> tuple[bool, int]:
with webapp.app_context():
config = ConfigurationHelper()
return bool(config.getValue("auto_rooms_enable")), config.getIntValue("auto_rooms_channel_id")
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)
with webapp.app_context():
record = AutoRoom.query.filter_by(guild_id=str(guild_id), voice_channel_id=str(channel_id)).first()
if record:
room = _get_room(guild_id, int(record.owner_id))
if room:
return (int(record.owner_id), room)
return None
@@ -223,7 +157,7 @@ def _find_room_by_message(message_id: int) -> Optional[tuple[int, int, dict]]:
return (guild_id, owner_id, data)
async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist: set, blacklist: set, room: dict):
async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist: set, blacklist: set):
guild = channel.guild
everyone = guild.default_role
overwrites = dict(channel.overwrites) # Récupérer les overwrites existants
@@ -240,11 +174,10 @@ async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist
if mode == "open":
everyone_ow.connect = True
everyone_ow.view_channel = True
# Ne toucher qu'aux overwrites créés par l'auto room, jamais aux droits
# ajoutés manuellement par la modération.
# Retirer les overwrites des membres qui ne sont plus dans la blacklist
for target in list(overwrites.keys()):
if target != everyone and isinstance(target, discord.Member):
if target.id in room.get("managed_member_ids", set()) and target.id not in blacklist:
if target.id not in blacklist:
overwrites.pop(target, None)
# Ajouter les overwrites pour la blacklist
for uid in blacklist:
@@ -254,9 +187,10 @@ async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist
elif mode == "closed":
everyone_ow.connect = False
everyone_ow.view_channel = True
# Retirer les overwrites des membres qui ne sont plus dans la whitelist
for target in list(overwrites.keys()):
if target != everyone and isinstance(target, discord.Member):
if target.id in room.get("managed_member_ids", set()) and target.id not in whitelist:
if target.id not in whitelist:
overwrites.pop(target, None)
# Ajouter les overwrites pour la whitelist
for uid in whitelist:
@@ -266,9 +200,10 @@ async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist
elif mode == "private":
everyone_ow.connect = False
everyone_ow.view_channel = False
# Retirer les overwrites des membres qui ne sont plus dans la whitelist
for target in list(overwrites.keys()):
if target != everyone and isinstance(target, discord.Member):
if target.id in room.get("managed_member_ids", set()) and target.id not in whitelist:
if target.id not in whitelist:
overwrites.pop(target, None)
# Ajouter les overwrites pour la whitelist
for uid in whitelist:
@@ -278,7 +213,6 @@ async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist
overwrites[everyone] = everyone_ow
await channel.edit(overwrites=overwrites)
room["managed_member_ids"] = set(blacklist if mode == "open" else whitelist)
async def _handle_reaction_action(bot: discord.Client, guild_id: int, owner_id: int, action: str, channel):
@@ -294,16 +228,8 @@ async def _handle_reaction_action(bot: discord.Client, guild_id: int, owner_id:
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()), room)
_persist_room(guild_id, owner_id, room)
# 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 **{_status_display(action)}**.")
await _apply_access_mode(voice_channel, action, room.get("whitelist", set()), room.get("blacklist", set()))
await channel.send(f"✅ Accès du salon défini sur **{_status_display(action)}**.")
# Mettre à jour l'embed
await _update_control_panel(bot, guild_id, owner_id, channel)
@@ -422,103 +348,20 @@ async def send_control_panel(bot: discord.Client, guild_id: int, owner: Member,
return None
_AUTO_ROOM_NAME_PATTERN = re.compile(r"^Salon de .+ [🔓🔒🔐]$")
async def cleanup_orphaned_auto_rooms(bot: discord.Client):
"""Supprime les auto rooms orphelines (vides) au démarrage du bot."""
enabled, trigger_channel_id = _auto_rooms_config()
if not enabled:
return
if not trigger_channel_id:
return
deleted = 0
# Les rooms persistées sont connues même si leur nom a été modifié manuellement.
for (guild_id, owner_id), room in list(_rooms.items()):
channel = bot.get_channel(room["voice_channel_id"])
if isinstance(channel, discord.VoiceChannel) and not channel.members:
try:
await channel.delete(reason="Nettoyage auto room vide au démarrage")
_del_room(guild_id, owner_id)
deleted += 1
except discord.HTTPException:
pass
for guild in bot.guilds:
trigger_channel = guild.get_channel(trigger_channel_id)
if not trigger_channel or not trigger_channel.category:
continue
category = trigger_channel.category
for channel in list(category.voice_channels):
if channel.id == trigger_channel_id:
continue
if not _AUTO_ROOM_NAME_PATTERN.match(channel.name):
continue
if len(channel.members) == 0:
try:
await channel.delete(reason="Nettoyage auto room orpheline au démarrage")
result = _find_room_by_channel(guild.id, channel.id)
if result:
_del_room(guild.id, result[0])
deleted += 1
except discord.HTTPException:
pass
if deleted > 0:
logging.info(f"Nettoyage auto rooms : {deleted} salon(s) orphelin(s) supprimé(s)")
async def restore_auto_rooms(bot: discord.Client):
"""Recharge les salons encore existants après un redémarrage du bot."""
with webapp.app_context():
records = AutoRoom.query.all()
for record in records:
guild = bot.get_guild(int(record.guild_id))
channel = guild.get_channel(int(record.voice_channel_id)) if guild else None
if not isinstance(channel, discord.VoiceChannel):
with webapp.app_context():
db.session.delete(db.session.merge(record))
db.session.commit()
continue
try:
whitelist = set(json.loads(record.whitelist or "[]"))
blacklist = set(json.loads(record.blacklist or "[]"))
managed_member_ids = set(json.loads(record.managed_member_ids or "[]"))
except (TypeError, ValueError):
whitelist, blacklist, managed_member_ids = set(), set(), set()
_set_room(int(record.guild_id), int(record.owner_id), {
"guild_id": int(record.guild_id),
"voice_channel_id": int(record.voice_channel_id),
"control_message_id": int(record.control_message_id) if record.control_message_id else None,
"owner_id": int(record.owner_id),
"whitelist": whitelist,
"blacklist": blacklist,
"managed_member_ids": managed_member_ids,
"access_mode": record.access_mode or "open",
})
async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member, before: VoiceState, after: VoiceState):
enabled, trigger_channel_id = _auto_rooms_config()
if not enabled:
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:
existing_room = _get_room(guild.id, member.id)
if existing_room:
old_channel = bot.get_channel(existing_room["voice_channel_id"])
if old_channel and isinstance(old_channel, discord.VoiceChannel):
# Ne jamais abandonner une room encore occupée : le propriétaire y retourne.
await member.move_to(old_channel)
return
_del_room(guild.id, member.id)
category = after.channel.category
channel_name = f"Salon de {member.display_name} {_status_emoji('open')}"
# Nom du salon (simple, sans emoji pour éviter le rate limiting)
channel_name = f"Salon de {member.display_name}"
try:
new_channel = await guild.create_voice_channel(
name=channel_name,
@@ -535,15 +378,10 @@ async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member,
"owner_id": member.id,
"whitelist": set(),
"blacklist": set(),
"managed_member_ids": set(),
"access_mode": "open",
}
control_message_id = await send_control_panel(bot, guild.id, member, new_channel, room_data)
if not control_message_id:
await new_channel.delete(reason="Panneau Auto Room impossible à créer")
await member.move_to(after.channel)
return
room_data["control_message_id"] = control_message_id
_set_room(guild.id, member.id, room_data)
@@ -551,18 +389,17 @@ async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member,
except discord.HTTPException as e:
logging.error(f"Erreur création auto room : {e}")
if before.channel and before.channel != after.channel and before.channel.id != trigger_channel_id:
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 and remaining:
new_owner = remaining[0]
if member.id == owner_id:
_del_room(guild.id, owner_id)
room["owner_id"] = new_owner.id
_set_room(guild.id, new_owner.id, room)
await before.channel.send(f"👑 {new_owner.mention} est maintenant propriétaire du salon.")
await _update_control_panel(bot, guild.id, new_owner.id, before.channel)
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:
@@ -575,8 +412,7 @@ async def on_message_auto_rooms(bot: discord.Client, message: discord.Message):
"""Gère les messages dans les salons vocaux pour les actions (statut, liste blanche/noire, etc.)."""
if message.author.bot:
return
enabled, _ = _auto_rooms_config()
if not enabled:
if not ConfigurationHelper().getValue("auto_rooms_enable"):
return
# Vérifier si c'est dans un salon vocal (partie texte)
@@ -628,11 +464,7 @@ async def on_message_auto_rooms(bot: discord.Client, message: discord.Message):
whitelist.add(target.id)
await message.channel.send(f"{target.mention} a été ajouté à la liste blanche.")
room["whitelist"] = whitelist
blacklist = room.get("blacklist", set())
blacklist.discard(target.id)
room["blacklist"] = blacklist
await _apply_access_mode(voice_channel, room.get("access_mode", "open"), whitelist, blacklist, room)
_persist_room(message.guild.id, owner_id, room)
await _apply_access_mode(voice_channel, room.get("access_mode", "open"), whitelist, room.get("blacklist", set()))
await _update_control_panel(bot, message.guild.id, owner_id, message.channel)
return
@@ -649,11 +481,7 @@ async def on_message_auto_rooms(bot: discord.Client, message: discord.Message):
blacklist.add(target.id)
await message.channel.send(f"{target.mention} a été ajouté à la liste noire.")
room["blacklist"] = blacklist
whitelist = room.get("whitelist", set())
whitelist.discard(target.id)
room["whitelist"] = whitelist
await _apply_access_mode(voice_channel, room.get("access_mode", "open"), whitelist, blacklist, room)
_persist_room(message.guild.id, owner_id, room)
await _apply_access_mode(voice_channel, room.get("access_mode", "open"), room.get("whitelist", set()), blacklist)
await _update_control_panel(bot, message.guild.id, owner_id, message.channel)
return
@@ -672,13 +500,12 @@ async def on_message_auto_rooms(bot: discord.Client, message: discord.Message):
room["owner_id"] = new_owner.id
_set_room(message.guild.id, new_owner.id, room)
# Renommer le salon
# Renommer le salon (optionnel, peut être commenté pour éviter rate limit)
try:
base_name = f"Salon de {new_owner.display_name}"
new_name = f"{base_name} {_status_emoji(room.get('access_mode', 'open'))}"
new_name = f"Salon de {new_owner.display_name}"
await voice_channel.edit(name=new_name)
except discord.HTTPException:
pass
except discord.HTTPException as e:
logging.warning(f"Impossible de renommer le salon lors du transfert : {e}")
await message.channel.send(f"✅ La propriété du salon a été transférée à {new_owner.mention}.")
await _update_control_panel(bot, message.guild.id, new_owner.id, message.channel)
@@ -689,8 +516,7 @@ async def on_raw_reaction_add_auto_rooms(bot: discord.Client, payload: discord.R
"""Seul le propriétaire peut réagir ; on retire la réaction des autres."""
if payload.user_id == bot.user.id:
return
enabled, _ = _auto_rooms_config()
if not enabled:
if not ConfigurationHelper().getValue("auto_rooms_enable"):
return
room_info = _find_room_by_message(payload.message_id)
if not room_info:
+1 -3
View File
@@ -1,4 +1,4 @@
# FreeLoot Discord : notifications depuis les flux LootScraper (Epic, Amazon Prime, GOG, Steam, etc.)
# FreeLoot Discord : notifications depuis le feed LootScraper (jeux gratuits Epic, Amazon Prime, GOG, etc.)
import asyncio
import logging
@@ -56,7 +56,6 @@ def _store_label_for_title(source_key: str) -> str:
"gog": "GOG",
"google_play": "Google Play",
"apple_app_store": "l'App Store",
"steam": "Steam",
}
return labels.get(source_key, "la boutique")
@@ -70,7 +69,6 @@ SOURCE_LOGO_URLS = {
"gog": "https://www.gog.com/favicon.ico",
"google_play": "https://play.google.com/favicon.ico",
"apple_app_store": "https://www.apple.com/favicon.ico",
"steam": "https://store.steampowered.com/favicon.ico",
}
+1 -1
View File
@@ -35,7 +35,7 @@ def _findFirstNotNotified(bundles) :
def _formatMessage(bundle):
choice = bundle['choices'][0]
date = datetime.datetime.fromtimestamp(bundle['endDate']/1000,datetime.UTC).strftime("%d %B %Y")
message = f"**Humble Bundle** propose un pack de jeu [{bundle['name']}]({bundle['url']}) contenant :\n"
message = f"@here **Humble Bundle** propose un pack de jeu [{bundle['name']}]({bundle['url']}) contenant :\n"
for game in choice["games"]:
message += f"- {game}\n"
message += f"Pour {choice['price']}€, disponible jusqu'au {date}."
+351 -682
View File
File diff suppressed because it is too large Load Diff
-282
View File
@@ -1,282 +0,0 @@
import asyncio
import logging
import re
import xml.etree.ElementTree as ET
import requests
from discord import Client
from database import db
from database.helpers import ConfigurationHelper
from database.models import PatreonPost
from webapp import webapp
logger = logging.getLogger('patreon-notification')
logger.setLevel(logging.INFO)
_patreon_first_check = True
def _get_mention_content() -> str:
raw = ConfigurationHelper().getValue("patreon_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 _strip_html(html: str, max_len: int = 300) -> str:
"""Extrait le texte brut depuis du HTML et tronque."""
if not html:
return ""
text = re.sub(r'<br\s*/?>', '\n', html)
text = re.sub(r'<[^>]+>', '', text)
text = re.sub(r'&nbsp;', ' ', text)
text = re.sub(r'&amp;', '&', text)
text = re.sub(r'&lt;', '<', text)
text = re.sub(r'&gt;', '>', text)
text = re.sub(r'&#\d+;', '', text)
text = re.sub(r'\n{3,}', '\n\n', text).strip()
if len(text) > max_len:
text = text[:max_len].rsplit(' ', 1)[0] + '...'
return text
def _extract_image(html: str) -> str | None:
"""Extrait la première URL d'image depuis le contenu HTML."""
if not html:
return None
match = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', html)
if match:
url = match.group(1)
if url.startswith("http"):
return url
return None
def _parse_item(item, creator_name: str) -> dict | None:
"""Parse un <item> RSS et retourne un dict avec les métadonnées."""
guid_elem = item.find('guid')
if guid_elem is None or not guid_elem.text:
return None
title_elem = item.find('title')
link_elem = item.find('link')
desc_elem = item.find('description')
pub_elem = item.find('pubDate')
return {
'guid': guid_elem.text.strip(),
'title': title_elem.text if title_elem is not None else 'Nouveau post',
'link': link_elem.text if link_elem is not None else '',
'description': desc_elem.text if desc_elem is not None else '',
'published_at': pub_elem.text if pub_elem is not None else '',
'creator': creator_name,
}
def _fetch_rss() -> tuple[list[dict], str] | None:
"""Fetch le RSS Patreon et retourne (posts, creator_name) ou None."""
helper = ConfigurationHelper()
creator = helper.getValue("patreon_creator")
if not creator or not str(creator).strip():
return None
rss_url = f"https://www.patreon.com/rss/{str(creator).strip()}"
try:
response = requests.get(rss_url, timeout=15)
except Exception as e:
logger.error(f"Patreon: erreur réseau lors de la récupération du RSS: {e}")
return None
if response.status_code != 200:
logger.error(f"Patreon: HTTP {response.status_code} pour {rss_url}")
return None
try:
root = ET.fromstring(response.content)
except ET.ParseError as e:
logger.error(f"Patreon: erreur de parsing XML: {e}")
return None
creator_name = creator
channel_elem = root.find('.//channel/title')
if channel_elem is not None and channel_elem.text:
creator_name = channel_elem.text
items = root.findall('.//item')
posts = []
for item in items:
parsed = _parse_item(item, creator_name)
if parsed:
posts.append(parsed)
return (posts, creator_name)
def _build_embed(post: dict):
import discord
title = post.get('title') or 'Nouveau post Patreon'
link = post.get('link') or ''
description = _strip_html(post.get('description') or '', max_len=350)
creator = post.get('creator') or 'Patreon'
image_url = _extract_image(post.get('description') or '')
helper = ConfigurationHelper()
try:
color = int(helper.getValue('patreon_embed_color') or 'F96854', 16)
except (ValueError, TypeError):
color = 0xF96854
embed = discord.Embed(
title=title,
url=link if link.startswith("http") else None,
color=color,
)
if description:
embed.description = description
embed.set_author(
name=creator,
icon_url="https://c5.patreon.com/external/favicon/favicon-32x32.png",
)
if image_url:
embed.set_image(url=image_url)
embed.set_footer(text="MamieHenriette \u2022 Patreon")
return embed
async def checkPatreonPosts(bot: Client):
global _patreon_first_check
with webapp.app_context():
helper = ConfigurationHelper()
if not helper.getValue("patreon_enable"):
return
channel_id = helper.getIntValue("patreon_channel_id")
if not channel_id:
return
channel = bot.get_channel(channel_id)
if not channel:
logger.warning("Patreon: canal Discord introuvable")
return
result = await asyncio.to_thread(_fetch_rss)
if not result:
return
posts, creator_name = result
if not posts:
logger.info("Patreon: aucun post trouvé dans le flux RSS")
return
if _patreon_first_check:
logger.info("Patreon: première vérification, synchronisation sans notification")
for post_data in posts:
guid = post_data['guid']
if not PatreonPost.query.get(guid):
try:
db.session.add(PatreonPost(
guid=guid,
title=post_data['title'],
link=post_data['link'],
description=post_data['description'],
published_at=post_data['published_at'],
notified=False,
))
db.session.commit()
except Exception as e:
logger.error(f"Patreon: erreur de synchronisation pour {guid}: {e}")
db.session.rollback()
_patreon_first_check = False
return
for post_data in posts:
guid = post_data['guid']
if PatreonPost.query.get(guid):
continue
try:
embed = _build_embed(post_data)
content = _get_mention_content()
await channel.send(content=content or None, embed=embed)
db.session.add(PatreonPost(
guid=guid,
title=post_data['title'],
link=post_data['link'],
description=post_data['description'],
published_at=post_data['published_at'],
notified=True,
))
db.session.commit()
logger.info(f"Patreon: notification envoyée pour '{post_data['title']}'")
except Exception as e:
logger.error(f"Patreon: envoi Discord échoué pour {guid}: {e}")
db.session.rollback()
async def _send_post_to_discord_async(bot: Client, guid: str) -> tuple[bool, str]:
"""Envoie un post Patreon sur Discord (appel manuel). Retourne (succès, message)."""
helper = ConfigurationHelper()
channel_id = helper.getIntValue("patreon_channel_id")
if not channel_id:
return (False, "Aucun canal Discord configuré pour Patreon.")
channel = bot.get_channel(channel_id)
if not channel:
return (False, "Canal Discord introuvable.")
post_db = PatreonPost.query.get(guid)
if not post_db:
return (False, "Post introuvable en base de données.")
creator = helper.getValue("patreon_creator") or "Patreon"
# Tenter de récupérer le nom du créateur depuis le RSS
result = _fetch_rss()
creator_name = result[1] if result else creator
post_data = {
'title': post_db.title or 'Nouveau post',
'link': post_db.link or '',
'description': post_db.description or '',
'creator': creator_name,
}
try:
embed = _build_embed(post_data)
content = _get_mention_content()
await channel.send(content=content or None, embed=embed)
post_db.notified = True
db.session.commit()
return (True, "Notification envoyée sur Discord.")
except Exception as e:
logger.error(f"Patreon: envoi manuel échoué pour {guid}: {e}")
db.session.rollback()
return (False, str(e))
def send_post_to_discord_sync(bot: Client, guid: str) -> tuple[bool, str]:
"""Appel synchrone pour envoyer un post sur Discord (depuis la webapp)."""
try:
future = asyncio.run_coroutine_threadsafe(
_send_post_to_discord_async(bot, guid),
bot.loop,
)
return future.result(timeout=15)
except Exception as e:
logger.error(f"Patreon: send_post_to_discord_sync: {e}")
return (False, str(e))
-123
View File
@@ -1,123 +0,0 @@
import logging
from typing import Any, List
import discord
from discord import app_commands
from database.helpers import ConfigurationHelper
from protondb import searhProtonDb
def _build_protondb_embed(games: List[Any]) -> discord.Embed:
total_games = len(games)
tier_colors = {'platinum': '🟣', 'gold': '🟡', 'silver': '', 'bronze': '🟤', 'borked': '🔴'}
content = ""
max_games = 15
for count, game in enumerate(games[:max_games]):
g_name = str(game.get('name'))
g_id = str(game.get('id'))
tier = str(game.get('tier') or 'N/A').lower()
tier_icon = tier_colors.get(tier, '')
new_entry = f"**[{g_name}](<https://www.protondb.com/app/{g_id}>)**\n{tier_icon} Classé **{tier.capitalize()}**"
ac_status = game.get('anticheat_status')
if ac_status:
status_lower = str(ac_status).lower()
ac_map = {
'supported': ('', 'Supporté'),
'running': ('⚠️', 'Fonctionne'),
'broken': ('', 'Cassé'),
'denied': ('🚫', 'Refusé'),
'planned': ('📅', 'Planifié')
}
ac_emoji, ac_label = ac_map.get(status_lower, ('', str(ac_status)))
acs = game.get('anticheats') or []
ac_list = ', '.join([str(ac) for ac in acs if ac])
new_entry += f" • [Anti-cheat {ac_emoji} {ac_label}"
if ac_list:
new_entry += f" ({ac_list})"
new_entry += f"](<https://areweanticheatyet.com/game/{g_id}>)"
new_entry += "\n\n"
if len(content) + len(new_entry) > 3900:
rest = len(games) - count
content += f"*... et {rest} autre{'s' if rest > 1 else ''} jeu{'x' if rest > 1 else ''}*"
break
content += new_entry
else:
rest = max(0, len(games) - max_games)
if rest > 0:
content += f"*... et {rest} autre{'s' if rest > 1 else ''} jeu{'x' if rest > 1 else ''}*"
return discord.Embed(
title=f"🎮 Résultats ProtonDB - **{total_games} jeu{'x' if total_games > 1 else ''} trouvé{'s' if total_games > 1 else ''}**",
description=content,
color=0x5865F2
)
async def _protondb_search_followup(interaction: discord.Interaction, query: str) -> None:
# Une seule réponse éditée (pas de followups en chaîne) : évite les fils « message introuvable »
# et supprime le besoin dun message séparé « Recherche en cours… ».
await interaction.response.defer()
try:
games = searhProtonDb(query)
except Exception as e:
logging.error(f"ProtonDB : searhProtonDb : {e}")
games = []
if len(games) == 0:
try:
await interaction.edit_original_response(
content=(
f"{interaction.user.mention} Je n'ai pas trouvé de jeux correspondant à **{query}**. "
"Es-tu sûr que le jeu est disponible sur Steam ?"
),
embed=None,
)
except Exception as e:
logging.error(f"ProtonDB : edit_original_response (vide) : {e}")
return
embed = _build_protondb_embed(games)
try:
await interaction.edit_original_response(content=None, embed=embed)
except Exception as e:
logging.error(f"ProtonDB : edit_original_response (embed) : {e}")
try:
await interaction.followup.send(embed=embed)
except Exception as e2:
logging.error(f"ProtonDB : followup de secours : {e2}")
async def _protondb_slash_impl(interaction: discord.Interaction, jeu: str, exemple: str) -> None:
if not ConfigurationHelper().getValue('proton_db_enable_enable'):
await interaction.response.send_message(
"❌ La commande ProtonDB n'est pas activée.",
ephemeral=True,
)
return
query = jeu.strip()
if not query:
await interaction.response.send_message(
f"⚠️ Indique le nom d'un jeu.\nExemple : `{exemple}`",
ephemeral=True,
)
return
await _protondb_search_followup(interaction, query)
@app_commands.command(name="protondb", description="Recherche un jeu sur ProtonDB (compatibilité Linux / Steam).")
@app_commands.describe(jeu="Nom du jeu (ex. Elden Ring)")
async def protondb_slash_command(interaction: discord.Interaction, jeu: str):
await _protondb_slash_impl(interaction, jeu, "/protondb jeu:Elden Ring")
@app_commands.command(name="pdb", description="Alias de /protondb — recherche un jeu sur ProtonDB.")
@app_commands.describe(jeu="Nom du jeu (ex. Elden Ring)")
async def pdb_slash_command(interaction: discord.Interaction, jeu: str):
await _protondb_slash_impl(interaction, jeu, "/pdb jeu:Elden Ring")
-218
View File
@@ -1,218 +0,0 @@
# Règlement Discord : rôle d'arrivée à la connexion, rôle validé au clic sur le bouton.
import asyncio
import logging
import discord
from discord import TextChannel
from discord.ui import Button, View
from webapp import webapp
from database import db
from database.helpers import ConfigurationHelper
RULES_BUTTON_CUSTOM_ID = "mamie_rules_accept"
DEFAULT_BUTTON_LABEL = "J'ai lu le règlement"
class AcceptRulesButton(Button):
def __init__(self, label: str):
super().__init__(
style=discord.ButtonStyle.success,
label=(label or DEFAULT_BUTTON_LABEL)[:80],
custom_id=RULES_BUTTON_CUSTOM_ID,
)
async def callback(self, interaction: discord.Interaction):
await handle_rules_accept(interaction)
class RulesAcceptView(View):
def __init__(self, button_label: str):
super().__init__(timeout=None)
self.add_item(AcceptRulesButton(button_label))
def register_persistent_rules_view(client: discord.Client) -> None:
with webapp.app_context():
label = (ConfigurationHelper().getValue("rules_button_label") or "").strip() or DEFAULT_BUTTON_LABEL
client.add_view(RulesAcceptView(label))
def _rules_ack_button_success_text(
validated_role: discord.Role,
presentation_ch: TextChannel | None,
) -> str:
base = f"c'est bon 😌 tu as maintenant le rôle **{validated_role.name}**."
if presentation_ch:
return f"{base} Tu peux aller te présenter dans {presentation_ch.mention}."
return base
async def handle_rules_accept(interaction: discord.Interaction) -> None:
if not interaction.guild:
await interaction.response.send_message("Action impossible dans ce contexte.", ephemeral=True)
return
try:
member = await interaction.guild.fetch_member(interaction.user.id)
except (discord.NotFound, discord.HTTPException):
member = interaction.user if isinstance(interaction.user, discord.Member) else None
if member is None:
await interaction.response.send_message("Action impossible dans ce contexte.", ephemeral=True)
return
with webapp.app_context():
config = ConfigurationHelper()
enabled = config.getValue("rules_ack_enable")
arrival_id = config.getIntValue("rules_arrival_role_id")
presentation_id = config.getIntValue("rules_presentation_channel_id")
validated_id = config.getIntValue("rules_validated_role_id")
if not enabled:
await interaction.response.send_message("Cette fonctionnalité est désactivée.", ephemeral=True)
return
if not validated_id:
await interaction.response.send_message("Rôle membre validé non configuré.", ephemeral=True)
return
validated_role = interaction.guild.get_role(validated_id)
if not validated_role:
await interaction.response.send_message("Rôle membre validé introuvable sur ce serveur.", ephemeral=True)
return
arrival_role = interaction.guild.get_role(arrival_id) if arrival_id else None
presentation_ch = interaction.guild.get_channel(presentation_id)
presentation_ch = presentation_ch if isinstance(presentation_ch, TextChannel) else None
success_text = _rules_ack_button_success_text(validated_role, presentation_ch)
# Le retrait du rôle d'arrivée est la marque persistante de l'acceptation.
# Le rôle validé peut ensuite être remplacé par le système de présentation ;
# dans ce cas, un nouveau clic ne doit surtout pas rejouer l'attribution.
if arrival_role and arrival_role not in member.roles:
await interaction.response.send_message(
"Tu as déjà accepté le règlement. Tes rôles ne seront pas modifiés.",
ephemeral=True,
)
return
if validated_role in member.roles:
await interaction.response.send_message(success_text, ephemeral=True)
return
try:
await member.add_roles(validated_role, reason="Acceptation du règlement (bouton)")
if arrival_role and arrival_role in member.roles:
await member.remove_roles(arrival_role, reason="Passage membre validé après charte")
except discord.Forbidden:
await interaction.response.send_message(
"Je n'ai pas la permission de modifier tes rôles (rôle du bot trop bas ou « Gérer les rôles » manquant).",
ephemeral=True,
)
return
except discord.HTTPException as e:
await interaction.response.send_message(f"Erreur Discord : {e}", ephemeral=True)
return
await interaction.response.send_message(success_text, ephemeral=True)
async def publish_rules_embed(bot: discord.Client) -> tuple[bool, str]:
with webapp.app_context():
config = ConfigurationHelper()
if not config.getValue("rules_ack_enable"):
return False, "Activez d'abord « Règlement avec bouton » et enregistrez la configuration."
channel_id = config.getIntValue("rules_channel_id")
body = (config.getValue("rules_embed_body") or "").strip()
title = (config.getValue("rules_embed_title") or "").strip() or "Bienvenue"
button_label = (config.getValue("rules_button_label") or "").strip() or DEFAULT_BUTTON_LABEL
old_mid = config.getIntValue("rules_message_id")
old_ch_id = config.getIntValue("rules_message_channel_id")
if not channel_id:
return False, "Choisissez un canal du règlement."
if not body:
return False, "Le texte du règlement est vide."
channel = bot.get_channel(channel_id)
if not channel or not isinstance(channel, TextChannel):
return False, "Canal du règlement introuvable."
if len(body) > 4096:
body = body[:4093] + "..."
embed = discord.Embed(title=title, description=body, color=discord.Color.blurple())
view = RulesAcceptView(button_label)
try:
if old_mid and old_ch_id:
old_ch = bot.get_channel(old_ch_id)
if old_ch and isinstance(old_ch, TextChannel):
try:
old_msg = await old_ch.fetch_message(old_mid)
await old_msg.delete()
except (discord.NotFound, discord.Forbidden, discord.HTTPException):
pass
msg = await channel.send(embed=embed, view=view)
with webapp.app_context():
ConfigurationHelper().createOrUpdate("rules_message_id", str(msg.id))
ConfigurationHelper().createOrUpdate("rules_message_channel_id", str(channel.id))
db.session.commit()
return True, "Message du règlement publié sur Discord."
except discord.Forbidden:
return False, "Permission refusée pour envoyer ou supprimer un message dans ce canal."
except Exception as e:
logging.exception("publish_rules_embed")
return False, str(e)
def publish_rules_embed_sync(bot: discord.Client) -> tuple[bool, str]:
try:
future = asyncio.run_coroutine_threadsafe(publish_rules_embed(bot), bot.loop)
return future.result(timeout=30)
except Exception as e:
logging.exception("publish_rules_embed_sync")
return False, str(e)
async def assign_rules_arrival_on_join(bot: discord.Client, member: discord.Member) -> None:
"""Attribue uniquement le rôle d'arrivée à la connexion (le rôle validé vient du bouton)."""
with webapp.app_context():
config = ConfigurationHelper()
if not config.getValue("rules_ack_enable"):
return
arrival_id = config.getIntValue("rules_arrival_role_id")
validated_id = config.getIntValue("rules_validated_role_id")
if not arrival_id:
return
guild = member.guild
arrival_role = guild.get_role(arrival_id)
if not arrival_role:
logging.warning("assign_rules_arrival_on_join: rôle d'arrivée %s introuvable sur %s", arrival_id, guild.id)
return
if validated_id:
validated_role = guild.get_role(validated_id)
if validated_role and validated_role in member.roles:
return
if arrival_role in member.roles:
return
try:
await member.add_roles(arrival_role, reason="Règlement : rôle d'arrivée à la connexion")
except discord.Forbidden:
logging.warning(
"assign_rules_arrival_on_join: permission refusée pour %s sur %s (hiérarchie des rôles ?)",
member.id,
guild.id,
)
except discord.HTTPException as e:
logging.warning("assign_rules_arrival_on_join: %s", e)
+54 -156
View File
@@ -2,7 +2,6 @@ import logging
import asyncio
import xml.etree.ElementTree as ET
import requests
import discord
from database import db
from database.models import YouTubeNotification
@@ -25,32 +24,14 @@ async def checkYouTubeVideos():
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}")
db.session.rollback()
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}")
db.session.rollback()
def _extract_embed_config(notification: YouTubeNotification) -> dict:
"""Extrait toutes les valeurs ORM nécessaires à l'envoi dans un dict plain Python.
Doit être appelé pendant que le contexte Flask est actif."""
return {
'notify_channel': notification.notify_channel,
'message_template': notification.message or '',
'embed_title': notification.embed_title,
'embed_description': notification.embed_description,
'embed_color': notification.embed_color or 'FF0000',
'embed_footer': notification.embed_footer,
'embed_author_name': notification.embed_author_name,
'embed_author_icon': (notification.embed_author_icon or '').strip(),
'embed_thumbnail': bool(notification.embed_thumbnail),
'embed_image': bool(notification.embed_image),
}
async def _checkChannelVideos(notification: YouTubeNotification, is_first_check: bool = False):
@@ -103,93 +84,64 @@ async def _checkChannelVideos(notification: YouTubeNotification, is_first_check:
if video_title and ('#shorts' in video_title.lower() or '#short' in video_title.lower()):
is_short = True
video_data = {
if notification.video_type == 'all':
videos.append((video_id, {
'title': video_title,
'url': video_url,
'published': published_at,
'channel_name': channel_name,
'thumbnail': thumbnail,
'is_short': is_short
}
if notification.video_type == 'all':
videos.append((video_id, video_data))
}))
elif notification.video_type == 'short' and is_short:
videos.append((video_id, video_data))
videos.append((video_id, {
'title': video_title,
'url': video_url,
'published': published_at,
'channel_name': channel_name,
'thumbnail': thumbnail,
'is_short': is_short
}))
elif notification.video_type == 'video' and not is_short:
videos.append((video_id, video_data))
videos.append((video_id, {
'title': video_title,
'url': video_url,
'published': published_at,
'channel_name': channel_name,
'thumbnail': thumbnail,
'is_short': is_short
}))
videos.sort(key=lambda x: x[1]['published'], reverse=True)
# Enregistrer toutes les vidéos du flux dans l'historique (les doublons sont ignorés)
for vid, vdata in videos:
_save_video_history(notification.id, vid, vdata, notified=False)
if videos:
latest_video_id, latest_video = videos[0]
if not videos:
return
latest_video_id, _ = videos[0]
if is_first_check or not notification.last_video_id:
# Au démarrage, on initialise le curseur sans annoncer l'historique.
# 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()
logger.info(f"YouTube: synchronisation initiale pour {channel_id}, dernière vidéo: {latest_video_id}")
return
if latest_video_id == notification.last_video_id:
# Vérifications normales ensuite
if not notification.last_video_id:
notification.last_video_id = latest_video_id
db.session.commit()
return
# Une chaîne peut publier plusieurs vidéos entre deux contrôles : le choix
# fonctionnel est d'annoncer uniquement la plus récente, jamais l'historique.
logger.info(f"Nouvelle vidéo détectée: {latest_video_id} pour la chaîne {channel_id}")
embed_config = _extract_embed_config(notification)
success = await _notifyVideo(embed_config, videos[0][1], latest_video_id)
if not success:
# Ne pas avancer le curseur : cette dernière vidéo sera réessayée au prochain cycle.
logger.warning(f"Notification échouée pour {latest_video_id}; nouvel essai au prochain contrôle")
return
_save_video_history(notification.id, latest_video_id, videos[0][1], notified=True)
if latest_video_id != notification.last_video_id:
logger.info(f"Nouvelle vidéo détectée: {latest_video_id} pour la chaîne {notification.channel_id}")
await _notifyVideo(notification, latest_video, latest_video_id)
notification.last_video_id = latest_video_id
db.session.commit()
except Exception as e:
logger.error(f"Erreur lors de la vérification des vidéos: {e}")
db.session.rollback()
def _save_video_history(notification_id: int, video_id: str, video_data: dict, notified: bool):
"""Enregistre une vidéo dans l'historique (ne fait rien si déjà présente)."""
from database.models import YouTubeVideoHistory
try:
existing = YouTubeVideoHistory.query.filter_by(
notification_id=notification_id, video_id=video_id
).first()
if existing:
if notified and not existing.notified:
existing.notified = True
db.session.commit()
return
entry = YouTubeVideoHistory(
notification_id=notification_id,
video_id=video_id,
title=video_data.get('title', 'Sans titre'),
url=video_data.get('url', f"https://www.youtube.com/watch?v={video_id}"),
channel_name=video_data.get('channel_name', 'Inconnu'),
thumbnail=video_data.get('thumbnail'),
published_at=video_data.get('published', ''),
is_short=video_data.get('is_short', False),
notified=notified,
)
db.session.add(entry)
db.session.commit()
except Exception as e:
logger.error(f"Erreur lors de l'enregistrement de l'historique vidéo: {e}")
db.session.rollback()
async def _notifyVideo(embed_config: dict, video_data: dict, video_id: str) -> bool:
"""Envoie la notification Discord. Retourne True si l'envoi a réussi."""
async def _notifyVideo(notification: YouTubeNotification, video_data: dict, video_id: str):
from discordbot import bot
try:
channel_name = video_data.get('channel_name', 'Inconnu')
@@ -199,9 +151,8 @@ async def _notifyVideo(embed_config: dict, video_data: dict, video_id: str) -> b
published_at = video_data.get('published', '')
is_short = video_data.get('is_short', False)
message_template = embed_config.get('message_template', '')
try:
message = message_template.format(
message = notification.message.format(
channel_name=channel_name or 'Inconnu',
video_title=video_title or 'Sans titre',
video_url=video_url,
@@ -210,16 +161,15 @@ async def _notifyVideo(embed_config: dict, video_data: dict, video_id: str) -> b
published_at=published_at or '',
is_short=is_short
)
except (KeyError, AttributeError, ValueError) as e:
logger.error(f"Erreur de formatage du message: {e}")
except KeyError as e:
logger.error(f"Variable manquante dans le message de notification: {e}")
message = f"🎥 Nouvelle vidéo de {channel_name}: [{video_title}]({video_url})"
logger.info(f"Envoi de notification YouTube: {message}")
return await _sendMessage(embed_config, message, video_url, thumbnail, video_title, channel_name, video_id, published_at, is_short)
bot.loop.create_task(_sendMessage(notification, message, video_url, thumbnail, video_title, channel_name, video_id, published_at, is_short))
except Exception as e:
logger.error(f"Erreur lors de la notification: {e}")
return False
def _format_embed_text(text: str, channel_name: str, video_title: str, video_url: str, video_id: str, thumbnail: str, published_at: str, is_short: bool) -> str:
@@ -240,29 +190,26 @@ def _format_embed_text(text: str, channel_name: str, video_title: str, video_url
return text
async def _sendMessage(embed_config: dict, message: str, video_url: str, thumbnail: str, video_title: str, channel_name: str, video_id: str, published_at: str, is_short: bool) -> bool:
"""Envoie le message Discord. Retourne True si l'envoi a réussi."""
async def _sendMessage(notification: YouTubeNotification, message: str, video_url: str, thumbnail: str, video_title: str, channel_name: str, video_id: str, published_at: str, is_short: bool):
from discordbot import bot
try:
channel_id = int(embed_config['notify_channel'])
discord_channel = bot.get_channel(channel_id)
discord_channel = bot.get_channel(notification.notify_channel)
if not discord_channel:
# Le salon peut ne pas être présent dans le cache local après une reconnexion.
discord_channel = await bot.fetch_channel(channel_id)
if not discord_channel:
logger.error(f"Canal Discord {channel_id} introuvable")
return False
logger.error(f"Canal Discord {notification.notify_channel} introuvable")
return
embed_title_text = _format_embed_text(embed_config['embed_title'], channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if embed_config['embed_title'] else video_title
embed_description = _format_embed_text(embed_config['embed_description'], channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if embed_config['embed_description'] else None
import discord
embed_title = _format_embed_text(notification.embed_title, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if notification.embed_title else video_title
embed_description = _format_embed_text(notification.embed_description, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if notification.embed_description else None
try:
embed_color = int(embed_config['embed_color'], 16)
embed_color = int(notification.embed_color or 'FF0000', 16)
except ValueError:
embed_color = 0xFF0000
embed = discord.Embed(
title=embed_title_text,
title=embed_title,
url=video_url,
color=embed_color
)
@@ -270,19 +217,18 @@ async def _sendMessage(embed_config: dict, message: str, video_url: str, thumbna
if embed_description:
embed.description = embed_description
author_name = _format_embed_text(embed_config['embed_author_name'], channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if embed_config['embed_author_name'] else channel_name
author_icon_raw = embed_config['embed_author_icon']
author_icon = author_icon_raw if author_icon_raw.startswith(("http://", "https://")) else "https://www.youtube.com/img/desktop/yt_1200.png"
author_name = _format_embed_text(notification.embed_author_name, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if notification.embed_author_name else channel_name
author_icon = notification.embed_author_icon if notification.embed_author_icon else "https://www.youtube.com/img/desktop/yt_1200.png"
embed.set_author(name=author_name, icon_url=author_icon)
if embed_config['embed_thumbnail'] and thumbnail:
if notification.embed_thumbnail and thumbnail:
embed.set_thumbnail(url=thumbnail)
if embed_config['embed_image'] and thumbnail:
if notification.embed_image and thumbnail:
embed.set_image(url=thumbnail)
if embed_config['embed_footer']:
footer_text = _format_embed_text(embed_config['embed_footer'], channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short)
if notification.embed_footer:
footer_text = _format_embed_text(notification.embed_footer, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short)
if footer_text:
embed.set_footer(text=footer_text)
@@ -291,54 +237,6 @@ async def _sendMessage(embed_config: dict, message: str, video_url: str, thumbna
else:
await discord_channel.send(embed=embed)
logger.info(f"Notification YouTube envoyée avec succès")
return True
except Exception as e:
logger.error(f"Erreur lors de l'envoi du message Discord: {e}")
return False
async def _send_video_notification_async(history_id: int) -> tuple[bool, str]:
"""Force l'envoi d'une notification pour une vidéo de l'historique. Retourne (succès, message)."""
from database.models import YouTubeVideoHistory
with webapp.app_context():
history = YouTubeVideoHistory.query.get(history_id)
if not history:
return (False, "Vidéo introuvable dans l'historique.")
notification = YouTubeNotification.query.get(history.notification_id)
if not notification:
return (False, "Notification YouTube associée introuvable.")
embed_config = _extract_embed_config(notification)
video_data = {
'title': history.title or 'Sans titre',
'url': history.url or f"https://www.youtube.com/watch?v={history.video_id}",
'channel_name': history.channel_name or 'Inconnu',
'thumbnail': history.thumbnail or '',
'published': history.published_at or '',
'is_short': history.is_short,
}
success = await _notifyVideo(embed_config, video_data, history.video_id)
if success:
history.notified = True
db.session.commit()
return (True, "Notification envoyée sur Discord.")
else:
db.session.rollback()
return (False, "Échec de l'envoi sur Discord.")
def send_video_notification_sync(history_id: int) -> tuple[bool, str]:
"""Appel synchrone pour forcer une notification (depuis la webapp)."""
from discordbot import bot
try:
future = asyncio.run_coroutine_threadsafe(
_send_video_notification_async(history_id),
bot.loop,
)
return future.result(timeout=15)
except Exception as e:
logger.error(f"send_video_notification_sync: {e}")
return (False, str(e))
+4 -18
View File
@@ -6,8 +6,6 @@ from html import unescape
import requests
FEED_URL = "https://feed.eikowagenknecht.com/lootscraper.xml"
STEAM_FEED_URL = "https://feed.eikowagenknecht.com/lootscraper_steam_game.xml"
FEED_URLS = (FEED_URL, STEAM_FEED_URL)
ATOM_NS = {"atom": "http://www.w3.org/2005/Atom"}
SOURCES = [
@@ -18,7 +16,6 @@ SOURCES = [
("gog", "GOG", "🎮"),
("google_play", "Google Play", "🤖"),
("apple_app_store", "Apple App Store", "🍎"),
("steam", "Steam", "🎮"),
]
@@ -40,8 +37,6 @@ def source_key_from_entry(title: str, link: str) -> str | None:
return "google_play"
if "APPLE APP STORE" in title_upper:
return "apple_app_store"
if "STEAM" in title_upper or "store.steampowered.com" in link_lower:
return "steam"
return None
@@ -56,7 +51,6 @@ def game_name_from_title(title: str) -> str:
"GOG (Game, Always Free) - ",
"Google Play (Game) - ",
"Apple App Store (Game) - ",
"Steam (Game, PC) - ",
):
if title.startswith(prefix):
return unescape(title[len(prefix) :].strip())
@@ -141,10 +135,10 @@ def extract_rating_from_content(content: str) -> str | None:
return raw if len(raw) > 0 and len(raw) < 200 else None
def _fetch_single_feed(feed_url: str) -> list[dict]:
"""Récupère et parse un flux Atom LootScraper."""
def fetch_feed() -> list[dict] | None:
"""Récupère et parse le flux Atom, retourne une liste d'entrées brutes."""
try:
r = requests.get(feed_url, timeout=15)
r = requests.get(FEED_URL, timeout=15)
r.raise_for_status()
root = ET.fromstring(r.content)
entries = []
@@ -174,15 +168,7 @@ def _fetch_single_feed(feed_url: str) -> list[dict]:
})
return entries
except Exception:
return []
def fetch_feed() -> list[dict] | None:
"""Récupère les flux LootScraper général et Steam."""
entries = []
for feed_url in FEED_URLS:
entries.extend(_fetch_single_feed(feed_url))
return entries or None
return None
def get_display_entries() -> list[dict]:
+2 -94
View File
@@ -1,6 +1,5 @@
import asyncio
import logging
import time
from twitchAPI.twitch import Twitch
from twitchAPI.type import AuthScope, ChatEvent
@@ -45,7 +44,6 @@ USER_SCOPE = [
async def _onReady(ready_event: EventData):
logging.info('Bot Twitch prêt')
twitchBot._loop = asyncio.get_running_loop()
with webapp.app_context():
channel = ConfigurationHelper().getValue('twitch_channel')
webapp.config["BOT_STATUS"]["twitch_connected"] = True
@@ -72,13 +70,6 @@ async def _onMessage(msg: ChatMessage):
# Stocker le message dans BOT_STATUS pour l'affichage web
with webapp.app_context():
from datetime import datetime
now_ts = time.time()
msg_timestamps = webapp.config["BOT_STATUS"].setdefault("twitch_msg_timestamps", [])
msg_timestamps.append(now_ts)
cutoff = now_ts - 60
webapp.config["BOT_STATUS"]["twitch_msg_timestamps"] = [ts for ts in msg_timestamps if ts >= cutoff]
webapp.config["BOT_STATUS"]["twitch_msg_per_minute"] = len(webapp.config["BOT_STATUS"]["twitch_msg_timestamps"])
message_data = {
'username': msg.user.name,
'text': msg.text,
@@ -113,45 +104,11 @@ async def _handleCustomCommand(msg: ChatMessage):
if commande:
permission = commande.twitch_permission or 'viewer'
if not _user_has_twitch_permission(msg, permission):
return
response = _replace_command_variables(commande.response, msg)
return # Pas de réponse = l'utilisateur n'a pas la permission
response = commande.response.replace('{user}', msg.user.name)
await msg.reply(response)
def _replace_command_variables(text: str, msg: ChatMessage) -> str:
"""Remplace les variables de template dans la réponse d'une commande."""
from datetime import datetime
result = text
result = result.replace('{user}', msg.user.name)
result = result.replace('{username}', msg.user.name)
result = result.replace('{channel}', msg.room.name)
bot_status = webapp.config.get("BOT_STATUS", {})
result = result.replace('{title}', bot_status.get("twitch_stream_title", ""))
result = result.replace('{game}', bot_status.get("twitch_game_name", ""))
result = result.replace('{viewers}', str(bot_status.get("twitch_viewer_count", 0)))
uptime_str = "hors ligne"
started_at_str = bot_status.get("twitch_started_at")
if started_at_str and bot_status.get("twitch_is_live", False):
try:
started_at = datetime.fromisoformat(started_at_str)
delta = datetime.now(started_at.tzinfo) - started_at
total_seconds = int(delta.total_seconds())
hours, remainder = divmod(max(0, total_seconds), 3600)
minutes, _ = divmod(remainder, 60)
if hours > 0:
uptime_str = f"{hours}h {minutes:02d}min"
else:
uptime_str = f"{minutes}min"
except (ValueError, TypeError):
pass
result = result.replace('{uptime}', uptime_str)
return result
async def _helloCommand(msg: ChatMessage):
await msg.reply(f'Bonjour {msg.user.name}')
@@ -167,7 +124,6 @@ def _isConfigured():
class TwitchBot():
_eventsub = None
_loop = None
async def _connect(self):
with webapp.app_context():
@@ -177,41 +133,13 @@ class TwitchBot():
self.twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
await self.twitch.set_user_authentication(helper.getValue('twitch_access_token'), USER_SCOPE, helper.getValue('twitch_refresh_token'))
self.chat = await Chat(self.twitch)
# Laisser des tentatives de reconnexion internes plus longues avant reboot complet du client
self.chat.reconnect_delay_steps = [0, 1, 2, 4, 8, 16, 32, 64, 128, 128, 128]
self.chat.register_event(ChatEvent.READY, _onReady)
self.chat.register_event(ChatEvent.MESSAGE, _onMessage)
self.chat.register_command('hello', _helloCommand)
self._register_moderation_commands()
self.chat.start()
disconnected_since = None
while True:
connected = self.chat.is_connected()
if connected:
disconnected_since = None
else:
if disconnected_since is None:
disconnected_since = time.time()
# Si la lib n'arrive pas à se reconnecter en interne pendant un moment, on relance la session complète.
elif time.time() - disconnected_since >= 90:
logging.warning("Chat Twitch déconnecté depuis plus de 90s, redémarrage de la session")
break
await asyncio.sleep(2)
except Exception as e:
logging.error(f'Échec de l\'authentification Twitch : {e}')
finally:
webapp.config["BOT_STATUS"]["twitch_connected"] = False
self._loop = None
try:
if hasattr(self, 'chat') and self.chat:
self.chat.stop()
except Exception:
pass
try:
if hasattr(self, 'twitch') and self.twitch:
await self.twitch.close()
except Exception:
pass
else:
logging.info("Twitch n'est pas configuré")
@@ -235,9 +163,6 @@ class TwitchBot():
async def cmd_multitwitch(msg): await moderation.multitwitch_command(msg, self.twitch)
async def cmd_permit(msg): await link_filter.permit_command(msg, self.twitch)
from twitchbot import protondb as protondb_twitch
async def cmd_pdb(msg): await protondb_twitch.protondb_command(msg)
self.chat.register_command('kick', cmd_timeout)
self.chat.register_command('to', cmd_timeout)
self.chat.register_command('timeout', cmd_timeout)
@@ -259,8 +184,6 @@ class TwitchBot():
self.chat.register_command('no_game', cmd_no_game)
self.chat.register_command('multitwitch', cmd_multitwitch)
self.chat.register_command('permit', cmd_permit)
self.chat.register_command('pdb', cmd_pdb)
self.chat.register_command('protondb', cmd_pdb)
async def _checkOnlineStreamers(self):
while True:
@@ -328,22 +251,7 @@ class TwitchBot():
await asyncio.sleep(120)
def begin(self):
retry_delay = 15
while True:
try:
if not _isConfigured():
logging.info("Twitch non configuré, nouvelle tentative dans 60s")
with webapp.app_context():
webapp.config["BOT_STATUS"]["twitch_connected"] = False
time.sleep(60)
continue
asyncio.run(self._connect())
logging.warning("Session Twitch perdue, reconnexion complète dans %ss", retry_delay)
except Exception as e:
logging.error("Déconnexion/erreur Twitch: %s", e)
with webapp.app_context():
webapp.config["BOT_STATUS"]["twitch_connected"] = False
time.sleep(retry_delay)
async def _close(self):
self.chat.stop()
+1 -1
View File
@@ -122,7 +122,7 @@ async def _handle_unauthorized_link(msg: ChatMessage, twitch: Twitch, config: di
logger.error(f"Erreur timeout link filter: {e}")
try:
await twitch.delete_chat_message(broadcaster_id, moderator_id, message_id=msg.id)
await twitch.delete_chat_messages(broadcaster_id, moderator_id, message_id=msg.id)
except Exception as e:
logger.error(f"Erreur suppression message: {e}")
+9 -65
View File
@@ -1,6 +1,4 @@
import logging
import asyncio
from datetime import datetime
import discord
from twitchAPI.twitch import Twitch
@@ -49,15 +47,7 @@ async def checkOnlineStreamer(twitch: Twitch) :
global _live_alert_first_check
with webapp.app_context() :
alerts : list[LiveAlert] = LiveAlert.query.all()
bot_status = webapp.config["BOT_STATUS"]
was_live = bot_status.get("twitch_is_live", False)
try:
streams = await _retreiveStreams(twitch, alerts)
except Exception as e:
logger.error(f'Erreur lors de la récupération des streams, on conserve l\'état actuel : {e}')
return
watch_stream = None
# Récupération du statut du live principal (channel configuré)
@@ -69,43 +59,17 @@ async def checkOnlineStreamer(twitch: Twitch) :
# Mise à jour du BOT_STATUS pour la webapp
if main_stream:
bot_status["twitch_is_live"] = True
bot_status["twitch_viewer_count"] = getattr(main_stream, 'viewer_count', 0)
bot_status["twitch_stream_title"] = getattr(main_stream, 'title', '') or ''
bot_status["twitch_game_name"] = getattr(main_stream, 'game_name', '') or ''
bot_status["twitch_started_at"] = main_stream.started_at.isoformat() if getattr(main_stream, 'started_at', None) else None
bot_status["twitch_ended_at"] = None
bot_status["twitch_chat_clear_notice_sent"] = False
webapp.config["BOT_STATUS"]["twitch_is_live"] = True
webapp.config["BOT_STATUS"]["twitch_viewer_count"] = getattr(main_stream, 'viewer_count', 0)
else:
bot_status["twitch_is_live"] = False
bot_status["twitch_viewer_count"] = 0
bot_status["twitch_stream_title"] = ""
bot_status["twitch_game_name"] = ""
bot_status["twitch_started_at"] = None
if was_live and not bot_status.get("twitch_ended_at"):
bot_status["twitch_ended_at"] = datetime.now().isoformat()
if was_live and not bot_status.get("twitch_chat_clear_notice_sent"):
messages = bot_status.setdefault("twitch_chat_messages", [])
now_iso = datetime.now().isoformat()
messages.append({
'username': 'System',
'text': 'Live terminé, ce chat sera vidé dans 1h.',
'timestamp': now_iso,
'is_mod': False,
'is_subscriber': False,
'is_vip': False,
'color': '#22c55e',
'panel_only': True,
})
if len(messages) > 100:
messages.pop(0)
bot_status["twitch_chat_clear_notice_sent"] = True
webapp.config["BOT_STATUS"]["twitch_is_live"] = False
webapp.config["BOT_STATUS"]["twitch_viewer_count"] = 0
# Premier check : synchronisation sans notification
if _live_alert_first_check:
logger.info('Live Alert: première vérification, synchronisation sans notification')
for alert in alerts:
stream = next((s for s in streams if s.user_login.lower() == (alert.login or '').lower()), None)
stream = next((s for s in streams if s.user_login == alert.login), None)
if stream:
alert.online = True
if alert.watch_activity and alert.enable:
@@ -119,7 +83,7 @@ async def checkOnlineStreamer(twitch: Twitch) :
# Vérifications normales ensuite
for alert in alerts :
stream = next((s for s in streams if s.user_login.lower() == (alert.login or '').lower()), None)
stream = next((s for s in streams if s.user_login == alert.login), None)
if stream :
logger.info(f'Streamer en ligne : {alert.login}')
if not alert.online and alert.enable :
@@ -137,27 +101,15 @@ async def checkOnlineStreamer(twitch: Twitch) :
async def _updateBotActivity(stream: Stream | None):
if not bot.loop or bot.loop.is_closed():
logger.warning("Loop Discord non disponible pour mise à jour de présence")
return
if stream:
logger.info(f'Mise à jour de l\'activité : Regarde le live de {stream.user_name}')
activity = discord.Streaming(
name=f'Regarde le live de {stream.user_name}',
url=f'https://www.twitch.tv/{stream.user_login}'
)
with webapp.app_context():
webapp.config["BOT_STATUS"]["discord_streaming_activity"] = True
future = asyncio.run_coroutine_threadsafe(
bot.change_presence(status=discord.Status.online, activity=activity),
bot.loop
)
future.result(timeout=10)
await bot.change_presence(status=discord.Status.online, activity=activity)
else:
logger.info('Aucun stream à regarder, retour à l\'activité normale')
with webapp.app_context():
webapp.config["BOT_STATUS"]["discord_streaming_activity"] = False
# Remettre une humeur aléatoire
from database.models import Humeur
import random
@@ -165,18 +117,10 @@ async def _updateBotActivity(stream: Stream | None):
if humeurs:
humeur = random.choice(humeurs)
logger.info(f'Réinitialisation du statut : {humeur.text}')
future = asyncio.run_coroutine_threadsafe(
bot.change_presence(status=discord.Status.online, activity=discord.CustomActivity(humeur.text)),
bot.loop
)
future.result(timeout=10)
await bot.change_presence(status=discord.Status.online, activity=discord.CustomActivity(humeur.text))
else:
# Si pas de humeur, remettre un statut par défaut
future = asyncio.run_coroutine_threadsafe(
bot.change_presence(status=discord.Status.online, activity=None),
bot.loop
)
future.result(timeout=10)
await bot.change_presence(status=discord.Status.online, activity=None)
async def _notifyAlert(alert: LiveAlert, stream: Stream):
stream_url = f'https://www.twitch.tv/{stream.user_login}'
+3 -3
View File
@@ -141,11 +141,11 @@ async def clean_command(msg: ChatMessage, twitch: Twitch):
viewer = args[0].lstrip('@')
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason="Purge messages", duration=1)
await twitch.delete_chat_messages(broadcaster_id, moderator_id, user_id=user_id)
_log_action("clean", msg.user.name, viewer)
logger.info(f'Messages de {viewer} supprimés par {msg.user.name}')
else:
await twitch.delete_chat_message(broadcaster_id, moderator_id)
await twitch.delete_chat_messages(broadcaster_id, moderator_id)
_log_action("clean", msg.user.name, None, "Chat complet")
logger.info(f'Chat nettoyé par {msg.user.name}')
@@ -433,7 +433,7 @@ async def check_message_for_banned_words(msg: ChatMessage, twitch: Twitch) -> bo
# Suppression du message
try:
await twitch.delete_chat_message(broadcaster_id, moderator_id, message_id=msg.id)
await twitch.delete_chat_messages(broadcaster_id, moderator_id, message_id=msg.id)
except Exception as e:
logger.error(f"Erreur suppression message mot interdit: {e}")
-104
View File
@@ -1,104 +0,0 @@
import asyncio
import logging
import time
from twitchAPI.chat import ChatMessage
from database.helpers import ConfigurationHelper
from protondb import searhProtonDb
from twitchbot import _user_has_twitch_permission
from webapp import webapp
_last_used: float = 0.0
TIER_ICONS = {
'platinum': '✅ Platinum',
'gold': '🥇 Gold',
'silver': '🥈 Silver',
'bronze': '🥉 Bronze',
'borked': '❌ Borked',
'native': '🐧 Native',
}
AC_ICONS = {
'supported': '',
'running': '⚠️',
'broken': '',
'denied': '🚫',
'planned': '📅',
}
def _format_game_response(game: dict) -> str:
name = game.get('name', '?')
tier = (game.get('tier') or '').lower()
tier_label = TIER_ICONS.get(tier, tier.capitalize() if tier else '?')
g_id = game.get('id', '')
parts = [f"[{name}] {tier_label}"]
ac_status = (game.get('anticheat_status') or '').lower()
if ac_status:
ac_icon = AC_ICONS.get(ac_status, '')
acs = game.get('anticheats') or []
ac_list = ', '.join(str(ac) for ac in acs if ac)
ac_part = f"Anti-cheat: {ac_icon} {ac_status.capitalize()}"
if ac_list:
ac_part += f" ({ac_list})"
parts.append(ac_part)
parts.append(f"protondb.com/app/{g_id}")
return ' | '.join(parts)
async def protondb_command(msg: ChatMessage):
global _last_used
with webapp.app_context():
if not ConfigurationHelper().getValue('proton_db_twitch_enable'):
return
permission = ConfigurationHelper().getValue('proton_db_twitch_permission') or 'viewer'
cooldown = int(ConfigurationHelper().getValue('proton_db_twitch_cooldown') or 0)
if not _user_has_twitch_permission(msg, permission):
return
if cooldown > 0:
elapsed = time.time() - _last_used
if elapsed < cooldown:
remaining = int(cooldown - elapsed)
await msg.reply(f"@{msg.user.name} La commande !pdb est en cooldown, réessaie dans {remaining}s.")
return
_last_used = time.time()
text = msg.text
for prefix in ('!protondb', '!pdb'):
if text.lower().startswith(prefix):
text = text[len(prefix):]
break
name = text.strip()
if not name:
await msg.reply(f"@{msg.user.name} Utilisation : !pdb <nom du jeu> Exemple : !pdb Elden Ring")
return
def _search():
with webapp.app_context():
return searhProtonDb(name)
try:
loop = asyncio.get_event_loop()
games = await loop.run_in_executor(None, _search)
except Exception as e:
logging.error(f'Erreur ProtonDB Twitch pour "{name}": {e}')
await msg.reply(f"@{msg.user.name} Erreur lors de la recherche ProtonDB.")
return
if not games:
await msg.reply(f"@{msg.user.name} Aucun jeu trouvé pour \"{name}\" sur Steam.")
return
for game in games[:3]:
response = _format_game_response(game)
if len(response) > 500:
response = response[:497] + '...'
await msg.reply(response)
+1 -10
View File
@@ -11,20 +11,11 @@ webapp.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in
webapp.config["BOT_STATUS"] = {
"discord_connected": False,
"discord_guild_count": 0,
"discord_streaming_activity": False,
"twitch_connected": False,
"twitch_channel_name": None,
"twitch_is_live": False,
"twitch_viewer_count": 0,
"twitch_stream_title": "",
"twitch_game_name": "",
"twitch_started_at": None,
"twitch_ended_at": None,
"twitch_chat_clear_notice_sent": False,
"twitch_msg_per_minute": 0,
"twitch_msg_timestamps": [], # Unix timestamps des 60 dernières secondes
"twitch_chat_messages": [], # Derniers messages du chat (max 100)
"shoutbox_heartbeats": {}, # {"username": datetime} — présence des modos
}
login_manager = LoginManager()
@@ -41,7 +32,7 @@ def load_user(user_id):
except (ValueError, TypeError):
return None
from webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot, patreon
from webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot
from flask import request, redirect, url_for
from flask_login import current_user
+3 -60
View File
@@ -1,35 +1,10 @@
import asyncio
import logging
from flask import render_template, request, redirect, url_for, flash
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page
from database import db
from database.helpers import ConfigurationHelper
from discordbot import bot
RULES_FORM_KEYS = frozenset({
'rules_channel_id',
'rules_arrival_role_id',
'rules_validated_role_id',
'rules_presentation_channel_id',
'rules_embed_title',
'rules_embed_body',
'rules_button_label',
})
SKIP_FORM_KEYS = frozenset({
'moderation_staff_role_ids',
'rules_ack_section_in_form',
'moderation_roles_in_form',
})
def _form_int_str(raw: str | None) -> str:
s = (raw or '').strip()
return s if s.isdigit() else '0'
@webapp.route("/configurations")
@require_page("configurations")
def openConfigurations():
@@ -41,40 +16,23 @@ def updateConfiguration():
checkboxes = {
'humble_bundle_enable': 'humble_bundle_channel',
'proton_db_enable_enable': 'proton_db_api_id',
'proton_db_twitch_enable': 'proton_db_api_id',
'moderation_enable': 'moderation_staff_role_ids',
'moderation_ban_enable': 'moderation_staff_role_ids',
'moderation_kick_enable': 'moderation_staff_role_ids',
'welcome_enable': 'welcome_channel_id',
'leave_enable': 'leave_channel_id',
'auto_rooms_enable': 'auto_rooms_channel_id',
'twitch_commands_enable': 'twitch_channel',
'rules_ack_enable': 'rules_channel_id',
'twitch_commands_enable': 'twitch_channel'
}
# Ne mettre à jour les rôles staff que si la liste a été rendue dans le formulaire.
# Sinon (bot pas encore prêt, guilds vides), getlist est vide et on écrasait la config en base.
staff_roles = request.form.getlist('moderation_staff_role_ids')
if request.form.get('moderation_roles_in_form'):
if staff_roles:
ConfigurationHelper().createOrUpdate('moderation_staff_role_ids', ','.join(staff_roles))
else:
ConfigurationHelper().createOrUpdate('moderation_staff_role_ids', '')
if request.form.get('rules_ack_section_in_form'):
ch = ConfigurationHelper()
ch.createOrUpdate('rules_channel_id', _form_int_str(request.form.get('rules_channel_id')))
ch.createOrUpdate('rules_arrival_role_id', _form_int_str(request.form.get('rules_arrival_role_id')))
ch.createOrUpdate('rules_validated_role_id', _form_int_str(request.form.get('rules_validated_role_id')))
ch.createOrUpdate('rules_presentation_channel_id', _form_int_str(request.form.get('rules_presentation_channel_id')))
ch.createOrUpdate('rules_embed_title', (request.form.get('rules_embed_title') or '').strip())
ch.createOrUpdate('rules_embed_body', request.form.get('rules_embed_body') or '')
ch.createOrUpdate('rules_button_label', (request.form.get('rules_button_label') or '').strip())
for key in request.form:
if key in SKIP_FORM_KEYS:
continue
if request.form.get('rules_ack_section_in_form') and key in RULES_FORM_KEYS:
if key == 'moderation_staff_role_ids':
continue
value = request.form.get(key)
if value and value.strip():
@@ -87,18 +45,3 @@ def updateConfiguration():
db.session.commit()
return redirect(request.referrer)
@webapp.route("/configurations/publish-rules", methods=['POST'])
@require_page("configurations")
def publishRulesMessage():
from discordbot.rules_ack import publish_rules_embed_sync
if not bot.loop or bot.loop.is_closed():
flash("Le bot Discord n'est pas connecté.", "error")
return redirect(url_for("openConfigurations"))
ok, msg = publish_rules_embed_sync(bot)
flash(msg, "success" if ok else "error")
if not ok:
logging.warning("publishRulesMessage: %s", msg)
return redirect(url_for("openConfigurations"))
-103
View File
@@ -1,103 +0,0 @@
from flask import render_template, request, redirect, url_for
from urllib.parse import urlencode
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.helpers import ConfigurationHelper
from database.models import PatreonPost
from discordbot import bot
from discordbot.patreon import send_post_to_discord_sync
def _parse_mention_config(raw: str | None) -> tuple[bool, bool, list[str]]:
everyone, here, role_ids = False, False, []
if not raw or not str(raw).strip():
return (everyone, here, role_ids)
for part in str(raw).strip().split(","):
part = part.strip()
if part == "everyone":
everyone = True
elif part == "here":
here = True
elif part.isdigit():
role_ids.append(part)
return (everyone, here, role_ids)
def _format_pub_date(raw: str | None) -> str:
if not raw or not str(raw).strip():
return ""
try:
from email.utils import parsedate_to_datetime
dt = parsedate_to_datetime(raw)
return dt.strftime("%d/%m/%Y %H:%M")
except Exception:
return raw[:16] if len(raw or "") >= 16 else (raw or "")
@webapp.route("/patreon")
@require_page("patreon")
def openPatreon():
helper = ConfigurationHelper()
channels = bot.getAllTextChannel()
roles = bot.getAllRoles()
raw_mention = helper.getValue("patreon_mention")
mention_everyone, mention_here, mention_role_ids = _parse_mention_config(raw_mention)
posts = PatreonPost.query.order_by(PatreonPost.published_at.desc()).all()
for p in posts:
p.published_formatted = _format_pub_date(p.published_at)
return render_template(
"patreon.html",
configuration=helper,
channels=channels,
roles=roles,
mention_everyone=mention_everyone,
mention_here=mention_here,
mention_role_ids=mention_role_ids,
posts=posts,
)
@webapp.route("/patreon/update", methods=["POST"])
@require_page("patreon")
def updatePatreon():
if not can_write_page("patreon"):
return render_template("403.html"), 403
helper = ConfigurationHelper()
enable = request.form.get("patreon_enable") in ("on", "1", "true", "yes")
creator = (request.form.get("patreon_creator") or "").strip()
channel_id = request.form.get("patreon_channel_id")
mention_parts = []
if request.form.get("patreon_mention_everyone"):
mention_parts.append("everyone")
if request.form.get("patreon_mention_here"):
mention_parts.append("here")
mention_parts.extend(request.form.getlist("patreon_mention_roles"))
helper.createOrUpdate("patreon_enable", "true" if enable else "false")
helper.createOrUpdate("patreon_creator", creator)
if channel_id:
try:
helper.createOrUpdate("patreon_channel_id", str(int(channel_id)))
except ValueError:
pass
helper.createOrUpdate("patreon_mention", ",".join(mention_parts))
db.session.commit()
return redirect(url_for("openPatreon") + "?msg=Configuration enregistrée.&type=success")
@webapp.route("/patreon/send", methods=["POST"])
@require_page("patreon")
def sendPatreonToDiscord():
if not can_write_page("patreon"):
return render_template("403.html"), 403
guid = (request.form.get("guid") or "").strip()
if not guid:
return redirect(url_for("openPatreon") + "?" + urlencode({"msg": "Post manquant.", "type": "error"}))
ok, message = send_post_to_discord_sync(bot, guid)
msg_type = "success" if ok else "error"
return redirect(url_for("openPatreon") + "?" + urlencode({"msg": message, "type": msg_type}))
-95
View File
@@ -73,90 +73,6 @@
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Règlement (embed + bouton)</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Le <strong>rôle d'arrivée</strong> est attribué <strong>dès qu'un membre rejoint le serveur</strong>. Le <strong>rôle membre validé</strong> est attribué <strong>uniquement</strong> quand il clique sur le bouton « J'ai lu le règlement » (le rôle d'arrivée est alors retiré s'il est encore présent). Le canal présentation sert uniquement d'indication dans le message de confirmation après le clic.
</p>
<input type="hidden" name="rules_ack_section_in_form" value="1">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="rules_ack_enable" {% if configuration.getValue('rules_ack_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer le règlement avec bouton</span>
</label>
<div>
<label for="rules_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal du règlement (message + bouton)</label>
<select name="rules_channel_id" id="rules_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
<option value="">— Choisir un canal —</option>
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('rules_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="rules_embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre de l'embed</label>
<input name="rules_embed_title" id="rules_embed_title" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('rules_embed_title') or '' }}"
placeholder="Bienvenue"/>
</div>
<div>
<label for="rules_embed_body" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Texte du règlement (description de l'embed, markdown Discord)</label>
<textarea name="rules_embed_body" id="rules_embed_body" rows="8"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all resize-y"
placeholder="Lis le règlement puis clique sur le bouton ci-dessous…">{{ configuration.getValue('rules_embed_body') or '' }}</textarea>
</div>
<div>
<label for="rules_button_label" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Libellé du bouton</label>
<input name="rules_button_label" id="rules_button_label" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('rules_button_label') or '' }}"
placeholder="J'ai lu le règlement"/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="rules_arrival_role_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôle d'arrivée (à la connexion uniquement)</label>
<select name="rules_arrival_role_id" id="rules_arrival_role_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
<option value="">— Aucun —</option>
{% for guild_data in roles %}
<optgroup label="{{ guild_data.guild_name }}">
{% for role in guild_data.roles %}
<option value="{{ role.id }}" {% if configuration.getIntValue('rules_arrival_role_id') == role.id %}selected{% endif %}>{{ role.name }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</div>
<div>
<label for="rules_validated_role_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôle membre validé (au clic sur le bouton uniquement)</label>
<select name="rules_validated_role_id" id="rules_validated_role_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
<option value="">— Aucun —</option>
{% for guild_data in roles %}
<optgroup label="{{ guild_data.guild_name }}">
{% for role in guild_data.roles %}
<option value="{{ role.id }}" {% if configuration.getIntValue('rules_validated_role_id') == role.id %}selected{% endif %}>{{ role.name }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</div>
</div>
<div>
<label for="rules_presentation_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal présentation (optionnel, texte d'aide après le bouton)</label>
<select name="rules_presentation_channel_id" id="rules_presentation_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
<option value="">— Désactivé —</option>
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('rules_presentation_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Mentionné dans le message éphémère après le clic (« Tu peux aller te présenter dans … »). Aucun rôle n'est attribué automatiquement sur ce canal.</p>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Messages de départ</h3>
@@ -243,9 +159,6 @@
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôles Staff autorisés</label>
{% if roles %}
<input type="hidden" name="moderation_roles_in_form" value="1">
{% endif %}
{% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %}
{% if roles|length > 1 %}
@@ -296,14 +209,6 @@
Enregistrer la configuration Discord
</button>
</form>
<form action="{{ url_for('publishRulesMessage') }}" method="POST" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">Envoie ou remplace le message du règlement sur Discord (utilise la config <strong>enregistrée</strong> ci-dessus).</p>
<button type="submit"
class="px-6 py-2.5 bg-teal-600 hover:bg-teal-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Publier le message règlement sur Discord
</button>
</form>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
+1 -1
View File
@@ -11,7 +11,7 @@
{% endif %}
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Notifications des jeux gratuits (Epic Games, Amazon Prime, GOG, Steam, Google Play, Apple App Store) via les flux
Notifications des jeux gratuits (Epic Games, Amazon Prime, GOG, Google Play, Apple App Store) via le flux
<a href="https://feed.eikowagenknecht.com/lootscraper.xml" target="_blank" rel="noopener" class="text-amber-700 dark:text-amber-300 hover:underline">LootScraper</a>.
Choisissez le canal Discord et les types de loot à notifier (PC, Android, iOS selon la source). Le bot vérifie le flux environ toutes les 30 minutes.
</p>
-206
View File
@@ -1,206 +0,0 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Patreon — Notifications de posts</h1>
{% if request.args.get('msg') %}
{% set msg_type = request.args.get('type') %}
<div class="mb-4 p-4 rounded-lg {% if msg_type == 'success' %}bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300{% elif msg_type == 'error' %}bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300{% else %}bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300{% endif %}">
{{ request.args.get('msg') }}
</div>
{% endif %}
<div class="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Notifications des nouveaux posts Patreon via le flux RSS public. Renseignez le nom du créateur Patreon
et choisissez le canal Discord de destination. Le bot vérifie le flux environ toutes les 10 minutes.
</p>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center gap-3 mb-6">
<svg class="w-8 h-8 text-orange-500" fill="currentColor" viewBox="0 0 24 24"><path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Configuration Patreon</h2>
</div>
<form action="{{ url_for('updatePatreon') }}" method="POST" class="space-y-6">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="patreon_enable" {% if configuration.getValue('patreon_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer les notifications Patreon</span>
</label>
</div>
<div>
<label for="patreon_creator" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom du créateur Patreon</label>
<input type="text" name="patreon_creator" id="patreon_creator"
value="{{ configuration.getValue('patreon_creator') or '' }}"
placeholder="ex: nom_du_createur"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Le nom tel qu'il apparaît dans l'URL : patreon.com/<strong>nom_du_createur</strong></p>
</div>
<div>
<label for="patreon_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal Discord pour les notifications</label>
<select name="patreon_channel_id" id="patreon_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all">
<option value="">— Choisir un canal —</option>
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('patreon_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Mentions (optionnel)</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Choisissez qui mentionner au début du message (avant l'embed).</p>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="patreon_mention_everyone" {% if mention_everyone %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">@everyone</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="patreon_mention_here" {% if mention_here %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">@here</span>
</label>
</div>
{% if roles %}
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôles à mentionner</p>
{% if roles|length > 1 %}
<div class="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-600 mb-3">
{% for guild_data in roles %}
<button type="button" class="patreon-role-tab px-4 py-2 text-sm font-medium rounded-t-lg transition-colors {% if loop.first %}bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}"
data-tab="patreon-roles-{{ guild_data.guild_id }}" {% if loop.first %}data-default{% endif %}>
{{ guild_data.guild_name }}
</button>
{% endfor %}
</div>
{% endif %}
{% for guild_data in roles %}
<div id="patreon-roles-{{ guild_data.guild_id }}" class="patreon-role-panel {% if not loop.first %}hidden{% endif %} max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-lg p-3 space-y-2">
{% for role in guild_data.roles %}
<label class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600/50 p-1 rounded">
<input type="checkbox" name="patreon_mention_roles" value="{{ role.id }}"
{% if role.id|string in mention_role_ids %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
{% if role.color is defined and role.color is not none and role.color.value != 0 %}
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color:#{{ '%06x'|format(role.color.value) }}"></span>
{% else %}
<span class="w-3 h-3 rounded-full flex-shrink-0 bg-gray-400"></span>
{% endif %}
<span class="text-sm text-gray-700 dark:text-gray-300">{{ role.name }}</span>
</label>
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
<button type="submit"
class="px-6 py-2.5 bg-orange-600 hover:bg-orange-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer
</button>
</form>
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-3">Aperçu de l'embed Discord</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Exemple du message envoyé dans le canal lors d'un nouveau post Patreon.</p>
<div class="inline-block rounded-r-lg overflow-hidden border border-gray-300 dark:border-gray-600 bg-[#2f3136] max-w-lg shadow-lg" style="border-left: 4px solid #F96854;">
<div class="p-4">
<div class="flex items-center gap-2 mb-2">
<img src="https://c5.patreon.com/external/favicon/favicon-32x32.png" alt="" class="w-6 h-6 rounded-full">
<span class="text-[#dcddde] text-sm font-medium">Nom du créateur</span>
</div>
<a href="#" class="text-[#00a8fc] hover:underline font-semibold text-base block mb-2">Titre du post Patreon</a>
<p class="text-[#dcddde] text-sm leading-relaxed mb-3">Ceci est un aperçu de la description du post Patreon. Le contenu HTML est automatiquement nettoyé et tronqué pour l'embed Discord...</p>
<div class="rounded overflow-hidden bg-[#202225] aspect-video flex items-center justify-center my-2">
<svg class="w-12 h-12 text-gray-500" fill="currentColor" viewBox="0 0 24 24"><path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
</div>
<p class="text-xs text-[#72767d] pt-1">MamieHenriette &bull; Patreon</p>
</div>
</div>
</div>
</div>
{% if posts %}
<div class="mt-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Historique des posts Patreon</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ posts|length }} post{{ 's' if posts|length > 1 else '' }} enregistré{{ 's' if posts|length > 1 else '' }}</p>
</div>
<div class="p-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{% for post in posts %}
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md transition-shadow flex flex-col">
<div class="p-4 flex flex-col flex-1">
<div class="flex items-start justify-between gap-2 mb-2">
<h3 class="font-semibold text-gray-900 dark:text-white line-clamp-2 flex-1" title="{{ post.title or 'Sans titre' }}">
{% if post.link %}
<a href="{{ post.link }}" target="_blank" rel="noopener noreferrer" class="hover:text-orange-600 dark:hover:text-orange-400 transition-colors">{{ post.title or 'Sans titre' }}</a>
{% else %}
{{ post.title or 'Sans titre' }}
{% endif %}
</h3>
{% if post.notified %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 flex-shrink-0">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Notifié
</span>
{% else %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 flex-shrink-0">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01"></path></svg>
Non notifié
</span>
{% endif %}
</div>
{% if post.description %}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-3">{{ post.description|striptags|truncate(150) }}</p>
{% endif %}
{% if post.published_formatted %}
<p class="text-xs text-gray-500 dark:text-gray-500 mb-3">{{ post.published_formatted }}</p>
{% endif %}
<div class="mt-auto">
<form action="{{ url_for('sendPatreonToDiscord') }}" method="POST" class="w-full">
<input type="hidden" name="guid" value="{{ post.guid }}">
<button type="submit" class="w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>
{% if post.notified %}Re-notifier{% else %}Envoyer sur Discord{% endif %}
</button>
</form>
</div>
</div>
</article>
{% endfor %}
</div>
</div>
</div>
</div>
{% else %}
<div class="mt-8 p-4 rounded-lg bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700">
<p class="text-gray-600 dark:text-gray-400 text-sm">Aucun post Patreon enregistré. Les posts apparaîtront ici après la première vérification du flux RSS.</p>
</div>
{% endif %}
<script>
document.querySelectorAll('.patreon-role-tab').forEach(btn => {
btn.addEventListener('click', function() {
var tabId = this.getAttribute('data-tab');
document.querySelectorAll('.patreon-role-panel').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('.patreon-role-tab').forEach(b => {
b.classList.remove('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
b.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
});
document.getElementById(tabId).classList.remove('hidden');
this.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
this.classList.add('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
});
});
document.querySelector('.patreon-role-tab[data-default]')?.click();
</script>
{% endblock %}
+2 -39
View File
@@ -10,7 +10,7 @@
</div>
</div>
{% if configuration.getValue('proton_db_enable_enable') or configuration.getValue('proton_db_twitch_enable') %}
{% if configuration.getValue('proton_db_enable_enable') %}
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Alias de jeux</h2>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
@@ -97,47 +97,10 @@
{% if configuration.getValue('proton_db_enable_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<label for="proton_db_enable_enable" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
Activer la commande ProtonDB sur Discord (<code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">!pdb</code>)
Activer la commande ProtonDB
</label>
</div>
<div class="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<input type="checkbox" name="proton_db_twitch_enable" id="proton_db_twitch_enable"
{% if configuration.getValue('proton_db_twitch_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<label for="proton_db_twitch_enable" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
Activer la commande ProtonDB sur Twitch (<code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">!pdb</code>)
</label>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label for="proton_db_twitch_permission" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Permission minimale pour <code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">!pdb</code> sur Twitch
</label>
<select name="proton_db_twitch_permission" id="proton_db_twitch_permission"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all">
{% set current_perm = configuration.getValue('proton_db_twitch_permission') or 'viewer' %}
<option value="viewer" {% if current_perm == 'viewer' %}selected{% endif %}>👁️ Viewer (tout le monde)</option>
<option value="sub" {% if current_perm == 'sub' %}selected{% endif %}>⭐ Abonné (Sub)</option>
<option value="vip" {% if current_perm == 'vip' %}selected{% endif %}>💎 VIP</option>
<option value="moderator" {% if current_perm == 'moderator' %}selected{% endif %}>🛡️ Modérateur</option>
</select>
</div>
<div>
<label for="proton_db_twitch_cooldown" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Cooldown entre deux <code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">!pdb</code> (secondes)
</label>
<input type="number" name="proton_db_twitch_cooldown" id="proton_db_twitch_cooldown"
min="0" max="3600" step="1"
value="{{ configuration.getValue('proton_db_twitch_cooldown') or 0 }}"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="0"/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">0 = pas de cooldown</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="proton_db_api_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API ID</label>
-296
View File
@@ -1,296 +0,0 @@
<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shoutbox Modos</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #030712; }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #374151; border-radius: 2px; }
</style>
</head>
<body class="bg-gray-950 text-gray-100 flex flex-col h-screen">
<div class="flex items-center justify-between px-3 py-1.5 bg-gray-900 border-b border-gray-800 flex-shrink-0">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-6a2 2 0 012-2h8z"></path></svg>
<span class="text-sm font-semibold text-gray-200">Shoutbox Modos</span>
<span class="text-xs text-gray-500" id="shoutboxCount"></span>
</div>
<div class="flex items-center gap-3 flex-wrap justify-end">
<div class="flex items-center gap-1.5">
<button type="button" onclick="shoutboxFontSize(-1)" class="text-gray-400 hover:text-white text-xs leading-none px-1" title="Réduire la police">A-</button>
<span class="text-gray-500 text-xs tabular-nums min-w-[1.25rem] text-center" id="shoutboxFontLabel">14</span>
<button type="button" onclick="shoutboxFontSize(1)" class="text-gray-400 hover:text-white text-xs leading-none px-1" title="Agrandir la police">A+</button>
</div>
<button type="button" onclick="toggleShoutboxSound()" id="shoutboxSoundBtn" class="text-xs flex items-center gap-1 text-green-400 hover:text-green-300" title="Son activé">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M17.95 6.05a8 8 0 010 11.9M11 5L6 9H2v6h4l5 4V5z"></path></svg>
<span id="shoutboxSoundLabel">Son</span>
</button>
<div id="onlineIndicator" class="flex items-center gap-1.5 text-xs text-gray-400">
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
<span id="onlineCountText">0 en ligne</span>
</div>
</div>
</div>
<div class="flex flex-1 overflow-hidden">
<div class="flex-1 relative">
<div class="absolute inset-0 overflow-y-auto font-mono p-2" id="shoutboxDisplay" style="scrollbar-width: thin;">
<div class="text-gray-500 text-center py-4" id="shoutboxPlaceholder">Aucun message</div>
</div>
<button id="newMsgBtn" onclick="scrollToBottom()" class="hidden absolute bottom-2 left-1/2 -translate-x-1/2 z-10 px-3 py-1 rounded-full bg-indigo-600/90 hover:bg-indigo-500 text-white text-xs font-medium shadow-lg animate-pulse flex items-center gap-1.5 backdrop-blur-sm">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"></path></svg>
<span id="newMsgText">Nouveaux messages</span>
</button>
</div>
<div class="w-24 border-l border-gray-800 bg-gray-900/50 p-2 overflow-y-auto flex-shrink-0" style="scrollbar-width: thin;">
<div class="text-xs text-gray-500 font-semibold mb-1">En ligne</div>
<div id="shoutboxOnlineList" class="space-y-1">
<div class="text-xs text-gray-600 italic">---</div>
</div>
</div>
</div>
<div class="border-t border-gray-800 p-2 bg-gray-900 flex-shrink-0">
<form onsubmit="sendShoutboxMessage(event)" class="flex gap-1.5">
<span class="text-xs text-indigo-400 font-mono flex items-center">&gt;</span>
<input type="text" id="shoutboxInput" placeholder="Message..." maxlength="500" autocomplete="off"
class="flex-1 px-2 py-1 rounded border border-gray-700 bg-gray-800 text-gray-100 text-xs font-mono focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none">
<button type="submit" class="px-3 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded text-xs font-medium transition-colors">Envoyer</button>
</form>
</div>
<script>
var knownIds = new Set();
var lastTimestamp = '';
var autoScroll = true;
var tabVisible = true;
var shoutboxAudioCtx = null;
var unreadCount = 0;
var shoutboxSoundEnabled = localStorage.getItem('shoutbox_sound') !== 'off';
var shoutboxFontSizePx = parseInt(localStorage.getItem('shoutbox_fontsize'), 10);
if (isNaN(shoutboxFontSizePx)) shoutboxFontSizePx = 14;
document.addEventListener('visibilitychange', function() { tabVisible = !document.hidden; });
function shoutboxApplyFontSize() {
var display = document.getElementById('shoutboxDisplay');
if (display) display.style.fontSize = shoutboxFontSizePx + 'px';
var label = document.getElementById('shoutboxFontLabel');
if (label) label.textContent = shoutboxFontSizePx;
localStorage.setItem('shoutbox_fontsize', String(shoutboxFontSizePx));
}
function shoutboxFontSize(delta) {
shoutboxFontSizePx = Math.max(8, Math.min(20, shoutboxFontSizePx + delta));
shoutboxApplyFontSize();
}
function shoutboxUpdateSoundUI() {
var btn = document.getElementById('shoutboxSoundBtn');
if (!btn) return;
btn.className = 'text-xs flex items-center gap-1 ' + (shoutboxSoundEnabled ? 'text-green-400 hover:text-green-300' : 'text-red-400 hover:text-red-300');
btn.title = shoutboxSoundEnabled ? 'Son activé (nouveaux messages si fenêtre en arrière-plan)' : 'Son désactivé';
var lab = document.getElementById('shoutboxSoundLabel');
if (lab) lab.textContent = shoutboxSoundEnabled ? 'Son' : 'Muet';
}
function toggleShoutboxSound() {
shoutboxSoundEnabled = !shoutboxSoundEnabled;
localStorage.setItem('shoutbox_sound', shoutboxSoundEnabled ? 'on' : 'off');
shoutboxUpdateSoundUI();
}
function shoutboxBeep() {
if (!shoutboxSoundEnabled) return;
try {
if (!shoutboxAudioCtx) shoutboxAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
var osc = shoutboxAudioCtx.createOscillator();
var gain = shoutboxAudioCtx.createGain();
osc.connect(gain);
gain.connect(shoutboxAudioCtx.destination);
osc.frequency.value = 660;
osc.type = 'sine';
gain.gain.setValueAtTime(0.15, shoutboxAudioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, shoutboxAudioCtx.currentTime + 0.3);
osc.start(shoutboxAudioCtx.currentTime);
osc.stop(shoutboxAudioCtx.currentTime + 0.3);
} catch(e) {}
}
var COLORS = ['#6366f1','#8b5cf6','#ec4899','#14b8a6','#f59e0b','#3b82f6','#10b981','#ef4444','#06b6d4','#84cc16'];
var colorMap = {};
function userColor(name) {
if (!colorMap[name]) {
var h = 0;
for (var i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h);
colorMap[name] = COLORS[Math.abs(h) % COLORS.length];
}
return colorMap[name];
}
var currentUser = '{{ current_user.username }}';
function esc(t) { var d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
function fmtTime(iso) {
var d = new Date(iso);
return d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0');
}
function mentionUser(username) {
var input = document.getElementById('shoutboxInput');
var val = input.value;
var mention = '@' + username + ' ';
if (val && !val.endsWith(' ')) mention = ' ' + mention;
input.value = val + mention;
input.focus();
}
function renderText(text) {
var escaped = esc(text);
var mentioned = currentUser && text.toLowerCase().indexOf('@' + currentUser.toLowerCase()) >= 0;
escaped = escaped.replace(/@(\w+)/g, '<span class="text-indigo-400 font-semibold">@$1</span>');
return { html: escaped, mentioned: mentioned };
}
function addItem(item) {
if (knownIds.has(item.id)) return;
knownIds.add(item.id);
var display = document.getElementById('shoutboxDisplay');
var ph = document.getElementById('shoutboxPlaceholder');
if (ph) ph.remove();
var line = document.createElement('div');
line.className = 'py-0.5 leading-relaxed rounded px-1';
var time = '<span class="text-gray-500">[' + fmtTime(item.created_at) + ']</span> ';
if (item.type === 'message') {
var c = userColor(item.author);
var rendered = renderText(item.text);
line.innerHTML = time + '<span style="color:' + c + '" class="font-semibold cursor-pointer hover:underline" onclick="mentionUser(\'' + esc(item.author).replace(/'/g, "\\'") + '\')">&lt;' + esc(item.author) + '&gt;</span> <span class="text-gray-200">' + rendered.html + '</span>';
if (rendered.mentioned) {
line.className += ' bg-red-900/40 font-bold';
}
} else {
var au = (item.action || '').toUpperCase();
var sc = 'text-red-400';
if (['timeout','clean','permit'].indexOf(item.action) >= 0) sc = 'text-orange-400';
if (['subon','suboff','emoteon','emoteoff','follon','folloff'].indexOf(item.action) >= 0) sc = 'text-blue-400';
if (item.action === 'unban') sc = 'text-green-400';
var txt = '*** ' + au;
if (item.moderator) txt += ' par ' + item.moderator;
if (item.target) txt += ' \u2192 ' + item.target;
if (item.details && item.details !== '-') txt += ' (' + item.details + ')';
txt += ' ***';
line.innerHTML = time + '<span class="' + sc + ' font-semibold">' + esc(txt) + '</span>';
}
display.appendChild(line);
if (!tabVisible) shoutboxBeep();
while (display.children.length > 200) display.removeChild(display.firstChild);
if (autoScroll) {
display.scrollTop = display.scrollHeight;
} else {
unreadCount++;
var btn = document.getElementById('newMsgBtn');
var txt = document.getElementById('newMsgText');
if (btn && txt) {
txt.textContent = unreadCount + ' nouveau' + (unreadCount > 1 ? 'x' : '') + ' message' + (unreadCount > 1 ? 's' : '');
btn.classList.remove('hidden');
}
}
}
function scrollToBottom() {
var d = document.getElementById('shoutboxDisplay');
d.scrollTop = d.scrollHeight;
autoScroll = true;
unreadCount = 0;
var btn = document.getElementById('newMsgBtn');
if (btn) btn.classList.add('hidden');
}
function updateOnline(users) {
var list = document.getElementById('shoutboxOnlineList');
var countEl = document.getElementById('onlineCountText');
if (countEl) countEl.textContent = (users ? users.length : 0) + ' en ligne';
if (!list) return;
if (!users || users.length === 0) { list.innerHTML = '<div class="text-xs text-gray-600 italic">---</div>'; return; }
list.innerHTML = '';
users.forEach(function(u) {
var el = document.createElement('div');
el.className = 'flex items-center gap-1.5 text-xs text-gray-300 cursor-pointer hover:text-white';
el.onclick = function() { mentionUser(u); };
el.innerHTML = '<span class="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0"></span>' + esc(u);
list.appendChild(el);
});
}
function poll() {
var url = '{{ url_for("shoutbox_messages") }}';
if (lastTimestamp) url += '?since=' + encodeURIComponent(lastTimestamp);
fetch(url)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.items && data.items.length > 0) {
data.items.forEach(addItem);
if (autoScroll) {
setTimeout(function() {
var d = document.getElementById('shoutboxDisplay');
d.scrollTop = d.scrollHeight;
}, 0);
}
}
if (data.timestamp) lastTimestamp = data.timestamp;
if (data.online_users) updateOnline(data.online_users);
var cnt = document.getElementById('shoutboxCount');
if (cnt) cnt.textContent = '(' + knownIds.size + ')';
})
.catch(function(e) { console.error('Poll error:', e); });
}
function heartbeat() {
fetch('{{ url_for("shoutbox_heartbeat") }}', { method: 'POST', headers: {'Content-Type':'application/json'}, body: '{}' })
.then(function(r) { return r.json(); })
.then(function(d) { updateOnline(d.online_users); })
.catch(function() {});
}
function sendShoutboxMessage(event) {
event.preventDefault();
var input = document.getElementById('shoutboxInput');
var msg = input.value.trim();
if (!msg) return;
var btn = event.target.querySelector('button[type="submit"]');
btn.disabled = true;
fetch('{{ url_for("shoutbox_send") }}', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({message: msg}) })
.then(function(r) { return r.json(); })
.then(function(d) { if (d.success) { input.value = ''; poll(); } })
.catch(function() {})
.finally(function() { btn.disabled = false; });
}
document.getElementById('shoutboxDisplay').addEventListener('scroll', function() {
autoScroll = this.scrollHeight - this.scrollTop <= this.clientHeight + 30;
if (autoScroll && unreadCount > 0) {
unreadCount = 0;
var btn = document.getElementById('newMsgBtn');
if (btn) btn.classList.add('hidden');
}
});
shoutboxApplyFontSize();
shoutboxUpdateSoundUI();
setInterval(poll, 3000);
poll();
setInterval(heartbeat, 10000);
heartbeat();
</script>
</body>
</html>
-8
View File
@@ -110,10 +110,6 @@
<span class="text-lg">🎁</span>
FreeLoot
</a>
<a href="{{ url_for('openPatreon') }}" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
Patreon
</a>
<div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
<a href="/commandes" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
@@ -257,10 +253,6 @@
<span class="text-lg">🎁</span>
FreeLoot
</a>
<a href="{{ url_for('openPatreon') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
Patreon
</a>
<a href="/configurations" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
Configurations
File diff suppressed because it is too large Load Diff
-135
View File
@@ -1,135 +0,0 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Historique des vidéos YouTube</h1>
<a href="{{ url_for('openYouTube') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 font-medium rounded-lg transition-colors text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path></svg>
Retour
</a>
</div>
{% if msg %}
<div id="alert-msg" class="mb-4 p-4 rounded-lg {{ 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300' if msg_type == 'error' else 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300' }}">
{{ msg }}
</div>
<script>
setTimeout(function() {
var el = document.getElementById('alert-msg');
if (el) el.style.display = 'none';
}, 5000);
</script>
{% endif %}
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6">
<p class="text-gray-700 dark:text-gray-300">
Historique des vidéos détectées par le bot. Les vidéos non notifiées peuvent être envoyées manuellement sur Discord.
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ total }} vidéo{{ 's' if total > 1 else '' }} au total.</p>
</div>
<div class="flex flex-wrap gap-2 mb-6" aria-label="Filtrer l'historique des vidéos">
<a href="{{ url_for('youtubeHistory') }}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {{ 'bg-red-600 text-white' if history_filter == 'all' else 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300' }}">
Toutes
</a>
<a href="{{ url_for('youtubeHistory', filter='video') }}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {{ 'bg-red-600 text-white' if history_filter == 'video' else 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300' }}">
Vidéos
</a>
<a href="{{ url_for('youtubeHistory', filter='short') }}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {{ 'bg-red-600 text-white' if history_filter == 'short' else 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300' }}">
Shorts
</a>
</div>
</div>
{% if history %}
<div class="space-y-4">
{% for entry in history %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md transition-shadow">
<div class="flex flex-col sm:flex-row">
{% if entry.thumbnail %}
<a href="{{ entry.url }}" target="_blank" class="shrink-0 sm:w-48 h-28 overflow-hidden bg-gray-100 dark:bg-gray-700">
<img src="{{ entry.thumbnail }}" alt="" class="w-full h-full object-cover">
</a>
{% endif %}
<div class="flex-1 p-4 flex flex-col justify-between min-w-0">
<div>
<div class="flex items-start justify-between gap-3 mb-1">
<a href="{{ entry.url }}" target="_blank" class="text-base font-semibold text-gray-900 dark:text-white hover:text-red-600 dark:hover:text-red-400 transition-colors truncate">
{{ entry.title or 'Sans titre' }}
</a>
<div class="shrink-0 flex items-center gap-2">
{% if entry.is_short %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300">Short</span>
{% endif %}
{% if entry.notified %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Notifié
</span>
{% else %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
Non notifié
</span>
{% endif %}
</div>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
<span>{{ entry.channel_name or 'Inconnu' }}</span>
{% if entry.published_at %}
<span>{{ entry.published_at[:10] }}</span>
{% endif %}
{% if notification_map.get(entry.notification_id) %}
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded font-mono">{{ notification_map[entry.notification_id].channel_id }}</span>
{% endif %}
</div>
</div>
<div class="flex items-center gap-2 mt-3">
<a href="{{ entry.url }}" target="_blank" class="px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
Voir sur YouTube
</a>
<form action="{{ url_for('forceYouTubeNotify', history_id=entry.id) }}" method="POST" class="inline"
onsubmit="return confirm('Envoyer la notification Discord pour cette vidéo ?')">
<button type="submit" class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1
{% if entry.notified %}
text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700
{% else %}
text-white bg-red-600 hover:bg-red-700
{% endif %}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
{{ 'Re-notifier' if entry.notified else 'Forcer la notification' }}
</button>
</form>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% if total_pages > 1 %}
<div class="mt-8 flex items-center justify-center gap-2">
{% if page > 1 %}
<a href="{{ url_for('youtubeHistory', page=page-1, filter=history_filter) }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors text-sm">
Précédent
</a>
{% endif %}
<span class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">
Page {{ page }} / {{ total_pages }}
</span>
{% if page < total_pages %}
<a href="{{ url_for('youtubeHistory', page=page+1, filter=history_filter) }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors text-sm">
Suivant
</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center">
<svg class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
<p class="text-gray-500 dark:text-gray-400">Aucune vidéo détectée pour le moment. L'historique se remplira au fur et à mesure des vérifications.</p>
</div>
{% endif %}
{% endblock %}
+1 -5
View File
@@ -16,16 +16,12 @@
</script>
{% endif %}
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 flex items-center justify-between">
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Liste des chaînes YouTube surveillées pour les notifications de nouvelles vidéos.
Le bot vérifie toutes les 5 minutes les nouvelles vidéos des chaînes en dessous.
Quand une nouvelle vidéo est détectée, le bot enverra une notification sur Discord.
</p>
<a href="{{ url_for('youtubeHistory') }}" class="ml-4 shrink-0 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
Historique
</a>
</div>
</div>
+42 -315
View File
@@ -2,34 +2,9 @@ from flask import render_template, request, redirect, url_for, jsonify
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import Commande, TwitchModerationLog, TwitchLinkFilter, TwitchBannedWord, ModShoutboxMessage
from flask_login import current_user
from database.models import Commande, TwitchModerationLog, TwitchLinkFilter, TwitchBannedWord
from database.helpers import ConfigurationHelper
from datetime import datetime, timedelta, timezone
def _format_stream_uptime(started_at_iso):
"""Durée depuis le début du live (texte court pour le panneau)."""
if not started_at_iso:
return None
try:
s = str(started_at_iso).replace("Z", "+00:00")
started = datetime.fromisoformat(s)
if started.tzinfo is None:
started = started.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
sec = int((now - started).total_seconds())
if sec < 0:
return None
h, sec = divmod(sec, 3600)
m, sec = divmod(sec, 60)
if h > 0:
return f"{h}h {m}min"
if m > 0:
return f"{m} min"
return "< 1 min"
except (ValueError, TypeError):
return None
from datetime import datetime, timedelta
import asyncio
MODERATION_COMMANDS = [
@@ -160,10 +135,6 @@ def twitch_moderation():
bot_status = webapp.config.get("BOT_STATUS", {})
is_live = bot_status.get("twitch_is_live", False)
viewer_count = bot_status.get("twitch_viewer_count", 0)
stream_title = bot_status.get("twitch_stream_title", "")
game_name = bot_status.get("twitch_game_name", "")
started_at = bot_status.get("twitch_started_at")
stream_uptime = _format_stream_uptime(started_at) if is_live else None
return render_template(
"twitch-moderation.html",
@@ -177,10 +148,6 @@ def twitch_moderation():
banned_words=banned_words,
is_live=is_live,
viewer_count=viewer_count,
stream_title=stream_title,
game_name=game_name,
started_at=started_at,
stream_uptime=stream_uptime,
)
@webapp.route("/twitch-moderation/logs/clear")
@@ -215,52 +182,6 @@ def add_twitch_commande():
return redirect(url_for('twitch_moderation'))
@webapp.route("/twitch-moderation/edit/<int:cmd_id>", methods=['POST'])
@require_page("twitch_moderation")
def edit_twitch_commande(cmd_id):
if not can_write_page("twitch_moderation"):
return jsonify({"success": False, "error": "Permission refusée"}), 403
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "Données invalides"}), 400
commande = Commande.query.get_or_404(cmd_id)
trigger = (data.get('trigger') or '').strip()
response = (data.get('response') or '').strip()
twitch_permission = data.get('twitch_permission', commande.twitch_permission or 'viewer')
if not trigger or not response:
return jsonify({"success": False, "error": "Commande et réponse requises"}), 400
if not trigger.startswith('!'):
trigger = '!' + trigger
if twitch_permission not in TWITCH_PERMISSIONS:
twitch_permission = 'viewer'
duplicate = Commande.query.filter(Commande.trigger == trigger, Commande.id != cmd_id).first()
if duplicate:
return jsonify({"success": False, "error": f"La commande {trigger} existe déjà"}), 409
commande.trigger = trigger
commande.response = response
commande.twitch_permission = twitch_permission
db.session.commit()
return jsonify({
"success": True,
"command": {
"id": commande.id,
"trigger": commande.trigger,
"response": commande.response,
"twitch_permission": commande.twitch_permission,
"permission_label": TWITCH_PERMISSIONS.get(commande.twitch_permission, 'Tous'),
}
})
@webapp.route("/twitch-moderation/banned-word/add", methods=['POST'])
@require_page("twitch_moderation")
def add_banned_word():
@@ -313,18 +234,24 @@ def send_twitch_message():
if not channel:
return jsonify({"success": False, "error": "Channel Twitch non configuré"}), 400
# Envoyer le message de manière asynchrone
try:
if not twitchBot._loop:
return jsonify({"success": False, "error": "Event loop du bot non disponible"}), 503
async def send_msg():
try:
await twitchBot.chat.send_message(channel, message)
return True
except Exception as e:
return str(e)
future = asyncio.run_coroutine_threadsafe(send_msg(), twitchBot._loop)
future.result(timeout=10)
# Exécuter la coroutine de manière synchrone
loop = asyncio.new_event_loop()
result = loop.run_until_complete(send_msg())
loop.close()
if result is True:
return jsonify({"success": True})
except TimeoutError:
return jsonify({"success": False, "error": "Timeout lors de l'envoi"}), 504
else:
return jsonify({"success": False, "error": f"Erreur: {result}"}), 500
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@@ -332,82 +259,8 @@ def send_twitch_message():
@require_page("twitch_moderation")
def get_twitch_messages():
"""Retourne les derniers messages du chat Twitch"""
bot_status = webapp.config["BOT_STATUS"]
clear_chat = False
clear_reason = None
ended_at_raw = bot_status.get("twitch_ended_at")
if ended_at_raw:
try:
ended_at = datetime.fromisoformat(ended_at_raw)
if datetime.now(ended_at.tzinfo) >= ended_at + timedelta(hours=1):
if bot_status.get("twitch_chat_messages"):
bot_status["twitch_chat_messages"] = []
bot_status["twitch_msg_timestamps"] = []
bot_status["twitch_msg_per_minute"] = 0
clear_chat = True
clear_reason = "Chat vidé automatiquement 1h après la fin du live."
except ValueError:
pass
messages = list(bot_status.get("twitch_chat_messages", []))
return jsonify({
"messages": messages,
"msg_per_min": int(bot_status.get("twitch_msg_per_minute", 0)),
"clear_chat": clear_chat,
"clear_reason": clear_reason,
})
@webapp.route("/twitch-moderation/stream-info")
@require_page("twitch_moderation")
def twitch_stream_info():
"""Retourne les infos du stream en cours pour le polling dynamique."""
bot_status = webapp.config.get("BOT_STATUS", {})
return jsonify({
"is_live": bot_status.get("twitch_is_live", False),
"viewer_count": bot_status.get("twitch_viewer_count", 0),
"title": bot_status.get("twitch_stream_title", ""),
"game_name": bot_status.get("twitch_game_name", ""),
"started_at": bot_status.get("twitch_started_at"),
"msg_per_min": int(bot_status.get("twitch_msg_per_minute", 0)),
})
@webapp.route("/twitch-moderation/logs/poll")
@require_page("twitch_moderation")
def poll_twitch_logs():
"""Retourne les logs de modération plus récents qu'un timestamp donné."""
since_str = request.args.get('since', '')
since = None
if since_str:
try:
since = datetime.fromisoformat(since_str)
except ValueError:
pass
query = TwitchModerationLog.query.order_by(TwitchModerationLog.created_at.desc())
if since:
query = query.filter(TwitchModerationLog.created_at > since)
logs = query.limit(20).all()
now = datetime.now().isoformat()
return jsonify({
"logs": [
{
"id": log.id,
"action": log.action,
"moderator": log.moderator,
"target": log.target or '-',
"details": log.details or '-',
"created_at": log.created_at.strftime('%d/%m %H:%M') if log.created_at else '',
"created_at_iso": log.created_at.isoformat() if log.created_at else '',
}
for log in logs
],
"timestamp": now,
"total": TwitchModerationLog.query.count(),
})
messages = webapp.config["BOT_STATUS"].get("twitch_chat_messages", [])
return jsonify({"messages": messages})
@webapp.route("/twitch-moderation/execute-action", methods=['POST'])
@require_page("twitch_moderation")
@@ -437,16 +290,14 @@ def execute_moderation_action():
from twitchAPI.chat import ChatMessage
from types import SimpleNamespace
admin_name = f"WebApp ({current_user.username})"
if not twitchBot._loop:
return jsonify({"success": False, "error": "Event loop du bot non disponible"}), 503
# Exécuter l'action de manière asynchrone
try:
async def execute_action():
try:
if action == 'timeout':
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
username = params.get('username', '').strip().lstrip('@')
duration = int(params.get('duration', 600))
duration = int(params.get('duration', 600)) # en secondes
reason = params.get('reason', 'Timeout')
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
@@ -455,7 +306,7 @@ def execute_moderation_action():
if user_id:
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason, duration=duration)
_log_action("timeout", admin_name, username, f"{duration}s - {reason}")
_log_action("timeout", "WebApp", username, f"{duration}s - {reason}")
return {"success": True, "message": f"Timeout de {username} pour {duration}s"}
return {"success": False, "error": f"Utilisateur {username} introuvable"}
@@ -470,7 +321,7 @@ def execute_moderation_action():
if user_id:
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason)
_log_action("ban", admin_name, username, reason)
_log_action("ban", "WebApp", username, reason)
return {"success": True, "message": f"Ban de {username}"}
return {"success": False, "error": f"Utilisateur {username} introuvable"}
@@ -484,19 +335,19 @@ def execute_moderation_action():
if username:
user_id = await _get_user_id(twitchBot.twitch, username)
if user_id:
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason="Purge messages", duration=1)
_log_action("clean", admin_name, username)
await twitchBot.twitch.delete_chat_messages(broadcaster_id, moderator_id, user_id=user_id)
_log_action("clean", "WebApp", username)
return {"success": True, "message": f"Messages de {username} supprimés"}
return {"success": False, "error": f"Utilisateur {username} introuvable"}
else:
await twitchBot.twitch.delete_chat_message(broadcaster_id, moderator_id)
_log_action("clean", admin_name, None, "Chat complet")
await twitchBot.twitch.delete_chat_messages(broadcaster_id, moderator_id)
_log_action("clean", "WebApp", None, "Chat complet")
return {"success": True, "message": "Chat nettoyé"}
elif action == 'permit':
from database.models import TwitchPermit
username = params.get('username', '').strip().lstrip('@').lower()
duration = int(params.get('duration', 60))
duration = int(params.get('duration', 60)) # en secondes
expires_at = datetime.now() + timedelta(seconds=duration)
@@ -519,158 +370,34 @@ def execute_moderation_action():
if action == 'subon':
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=True)
_log_action("subon", admin_name)
_log_action("subon", "WebApp")
return {"success": True, "message": "Mode abonnés activé"}
elif action == 'suboff':
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=False)
_log_action("suboff", admin_name)
_log_action("suboff", "WebApp")
return {"success": True, "message": "Mode abonnés désactivé"}
elif action == 'emoteon':
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=True)
_log_action("emoteon", admin_name)
_log_action("emoteon", "WebApp")
return {"success": True, "message": "Mode emote activé"}
elif action == 'emoteoff':
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=False)
_log_action("emoteoff", admin_name)
_log_action("emoteoff", "WebApp")
return {"success": True, "message": "Mode emote désactivé"}
return {"success": False, "error": f"Action '{action}' non reconnue"}
try:
future = asyncio.run_coroutine_threadsafe(execute_action(), twitchBot._loop)
result = future.result(timeout=15)
return jsonify(result)
except TimeoutError:
return jsonify({"success": False, "error": "Timeout lors de l'exécution"}), 504
except Exception as e:
import logging
logging.error(f"Erreur lors de l'exécution de l'action {action}: {e}")
return {"success": False, "error": str(e)}
# Exécuter la coroutine de manière synchrone
loop = asyncio.new_event_loop()
result = loop.run_until_complete(execute_action())
loop.close()
return jsonify(result)
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
# =============================
# Shoutbox modérateurs
# =============================
@webapp.route("/twitch-moderation/shoutbox/send", methods=['POST'])
@require_page("twitch_moderation")
def shoutbox_send():
if not can_write_page("twitch_moderation"):
return jsonify({"success": False, "error": "Permission refusée"}), 403
data = request.get_json()
message = (data.get('message') or '').strip()[:500]
if not message:
return jsonify({"success": False, "error": "Message vide"}), 400
msg = ModShoutboxMessage(
author=current_user.username,
message=message,
created_at=datetime.now(),
)
db.session.add(msg)
db.session.commit()
return jsonify({"success": True, "id": msg.id})
@webapp.route("/twitch-moderation/shoutbox/transfer", methods=['POST'])
@require_page("twitch_moderation")
def shoutbox_transfer():
if not can_write_page("twitch_moderation"):
return jsonify({"success": False, "error": "Permission refusée"}), 403
data = request.get_json() or {}
username = (data.get('username') or '').strip().lstrip('@')
message = (data.get('message') or '').strip()
if not username or not message:
return jsonify({"success": False, "error": "Données incomplètes"}), 400
text = f"@{username}: {message}"
msg = ModShoutboxMessage(
author=current_user.username,
message=text[:500],
created_at=datetime.now(),
)
db.session.add(msg)
db.session.commit()
return jsonify({"success": True, "id": msg.id})
@webapp.route("/twitch-moderation/shoutbox/messages")
@require_page("twitch_moderation")
def shoutbox_messages():
since_str = request.args.get('since', '')
since = None
if since_str:
try:
since = datetime.fromisoformat(since_str)
except ValueError:
pass
chat_query = ModShoutboxMessage.query
log_query = TwitchModerationLog.query
if since:
chat_query = chat_query.filter(ModShoutboxMessage.created_at > since)
log_query = log_query.filter(TwitchModerationLog.created_at > since)
chat_msgs = chat_query.order_by(ModShoutboxMessage.created_at.desc()).limit(100).all()
log_msgs = log_query.order_by(TwitchModerationLog.created_at.desc()).limit(100).all()
items = []
for m in chat_msgs:
items.append({
"type": "message",
"id": f"msg-{m.id}",
"author": m.author,
"text": m.message,
"created_at": m.created_at.isoformat() if m.created_at else '',
})
for log in log_msgs:
items.append({
"type": "sanction",
"id": f"log-{log.id}",
"action": log.action,
"moderator": log.moderator,
"target": log.target or '',
"details": log.details or '',
"created_at": log.created_at.isoformat() if log.created_at else '',
})
items.sort(key=lambda x: x["created_at"])
items = items[-100:]
return jsonify({
"items": items,
"timestamp": datetime.now().isoformat(),
"online_users": _get_online_users(),
})
def _get_online_users():
heartbeats = webapp.config["BOT_STATUS"].get("shoutbox_heartbeats", {})
cutoff = datetime.now() - timedelta(seconds=15)
return sorted(u for u, t in heartbeats.items() if t > cutoff)
@webapp.route("/twitch-moderation/shoutbox/heartbeat", methods=['POST'])
@require_page("twitch_moderation")
def shoutbox_heartbeat():
hb = webapp.config["BOT_STATUS"].setdefault("shoutbox_heartbeats", {})
hb[current_user.username] = datetime.now()
return jsonify({"online_users": _get_online_users()})
@webapp.route("/twitch-moderation/shoutbox/clear")
@require_page("twitch_moderation")
def shoutbox_clear():
if not can_write_page("twitch_moderation"):
return jsonify({"success": False, "error": "Permission refusée"}), 403
ModShoutboxMessage.query.delete()
db.session.commit()
return jsonify({"success": True})
@webapp.route("/twitch-moderation/shoutbox/popout")
@require_page("twitch_moderation")
def shoutbox_popout():
return render_template("shoutbox-popout.html")
+3 -61
View File
@@ -5,7 +5,7 @@ from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import YouTubeNotification, YouTubeVideoHistory
from database.models import YouTubeNotification
from discordbot import bot
@@ -112,7 +112,7 @@ def addYouTube():
embed_color=embed_color,
embed_footer=request.form.get('embed_footer') or None,
embed_author_name=request.form.get('embed_author_name') or None,
embed_author_icon=(request.form.get('embed_author_icon') or '').strip() or None,
embed_author_icon=request.form.get('embed_author_icon') or None,
embed_thumbnail=request.form.get('embed_thumbnail') == 'on',
embed_image=request.form.get('embed_image') == 'on'
)
@@ -177,7 +177,7 @@ def submitEditYouTube(id):
notification.embed_color = embed_color
notification.embed_footer = request.form.get('embed_footer') or None
notification.embed_author_name = request.form.get('embed_author_name') or None
notification.embed_author_icon = (request.form.get('embed_author_icon') or '').strip() or None
notification.embed_author_icon = request.form.get('embed_author_icon') or None
notification.embed_thumbnail = request.form.get('embed_thumbnail') == 'on'
notification.embed_image = request.form.get('embed_image') == 'on'
db.session.commit()
@@ -190,64 +190,6 @@ def delYouTube(id):
if not can_write_page("youtube"):
return render_template("403.html"), 403
notification = YouTubeNotification.query.get_or_404(id)
YouTubeVideoHistory.query.filter_by(notification_id=id).delete()
db.session.delete(notification)
db.session.commit()
return redirect(url_for("openYouTube"))
@webapp.route("/youtube/history")
@require_page("youtube")
def youtubeHistory():
page = request.args.get('page', 1, type=int)
per_page = 20
history_filter = request.args.get('filter', 'all')
if history_filter not in {'all', 'video', 'short'}:
history_filter = 'all'
history_query = YouTubeVideoHistory.query
if history_filter == 'video':
history_query = history_query.filter(YouTubeVideoHistory.is_short.is_(False))
elif history_filter == 'short':
history_query = history_query.filter(YouTubeVideoHistory.is_short.is_(True))
# published_at provient du flux YouTube (format ISO 8601), ce qui permet de
# présenter les vidéos par date de publication, plutôt que par date de détection.
history_query = history_query.order_by(
YouTubeVideoHistory.published_at.desc(),
YouTubeVideoHistory.detected_at.desc(),
)
total = history_query.count()
history = history_query.offset((page - 1) * per_page).limit(per_page).all()
total_pages = (total + per_page - 1) // per_page
notification_map = {}
for entry in history:
if entry.notification_id not in notification_map:
notif = YouTubeNotification.query.get(entry.notification_id)
notification_map[entry.notification_id] = notif
msg = request.args.get('msg')
msg_type = request.args.get('type', 'info')
return render_template(
"youtube-history.html",
history=history,
notification_map=notification_map,
page=page,
total_pages=total_pages,
total=total,
history_filter=history_filter,
msg=msg,
msg_type=msg_type,
)
@webapp.route("/youtube/notify/<int:history_id>", methods=['POST'])
@require_page("youtube")
def forceYouTubeNotify(history_id):
if not can_write_page("youtube"):
return render_template("403.html"), 403
from discordbot.youtube import send_video_notification_sync
success, message = send_video_notification_sync(history_id)
msg_type = 'success' if success else 'error'
return redirect(url_for("youtubeHistory") + "?" + urlencode({'msg': message, 'type': msg_type}))