Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cf0340a26 | ||
|
|
89845b3248 | ||
|
|
c962f57031 | ||
|
|
3da5ee1dab | ||
|
|
7d57b82b29 | ||
|
|
ae6d491c44 | ||
|
|
8eb6917337 | ||
|
|
e22f7b2d0f | ||
|
|
7fcb196bf3 | ||
|
|
e23f0a0385 | ||
|
|
4e6f8bec3a | ||
|
|
8634f946a1 | ||
|
|
69d8167581 | ||
|
|
e72647612d | ||
|
|
0c3048660a | ||
|
|
9d840e9bde | ||
|
|
769251df0d | ||
|
|
f901db1a7d | ||
|
|
2b8b725bbb | ||
|
|
3e57accb11 | ||
|
|
ecf59d645e | ||
|
|
ec296dd226 | ||
|
|
b51430e2b2 | ||
|
|
3c7d7f4e80 | ||
|
|
10923f58c3 | ||
|
|
855c13c183 | ||
|
|
bb92c3dd92 | ||
|
|
c4dab1d873 | ||
|
|
9cb4186bb8 | ||
|
|
f90c8eed81 | ||
|
|
fc274b47ae | ||
|
|
a93d5cda70 | ||
|
|
c9f27bf09e | ||
|
|
0453af255f | ||
|
|
26c9d0dc06 | ||
|
|
c265149357 | ||
|
|
47bc7146de | ||
|
|
f4c9fa2138 | ||
|
|
245aaf9c6d | ||
|
|
6ef0b6856e | ||
|
|
af86ba9a8e | ||
|
|
399b7c42fd | ||
|
|
ce5f60a2d7 | ||
|
|
1bf24d299c | ||
|
|
c8c1e0c283 | ||
|
|
d7b78ec1c4 | ||
|
|
6d364f28f1 | ||
|
|
589cb1f428 | ||
|
|
c44e624f53 | ||
|
|
2e83096550 | ||
|
|
540d23a3cf | ||
|
|
a2ade9deeb | ||
|
|
eedc7e4203 | ||
|
|
b8ead19360 | ||
|
|
64c043feb5 | ||
|
|
10dd78f630 | ||
|
|
876eb1a080 | ||
|
|
dc14b5193f | ||
|
|
137b6942ae | ||
|
|
77c3c11556 | ||
|
|
4eb7f304dc | ||
|
|
d24be973b8 | ||
|
|
5ee5c16cf3 | ||
|
|
5e4af406ff | ||
|
|
ed0bd4b661 | ||
|
|
4d8038cc77 |
+18
-1
@@ -166,6 +166,23 @@ 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:
|
||||
@@ -210,7 +227,6 @@ 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
|
||||
@@ -251,6 +267,7 @@ 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),
|
||||
|
||||
+46
-1
@@ -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.String(512))
|
||||
value = db.Column(db.Text)
|
||||
|
||||
class Humeur(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
@@ -195,6 +195,34 @@ 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)
|
||||
@@ -227,3 +255,20 @@ 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)
|
||||
|
||||
+44
-1
@@ -1,7 +1,7 @@
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `configuration` (
|
||||
`key` VARCHAR(32) PRIMARY KEY,
|
||||
`value` VARCHAR(512) NOT NULL
|
||||
`value` TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `game_alias` (
|
||||
@@ -178,6 +178,33 @@ 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
|
||||
);
|
||||
@@ -198,3 +225,19 @@ 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
|
||||
);
|
||||
|
||||
+61
-123
@@ -14,31 +14,58 @@ 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
|
||||
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,
|
||||
)
|
||||
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
|
||||
from protondb import searhProtonDb
|
||||
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
|
||||
|
||||
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):
|
||||
self.tree.add_command(transfer_message_context_menu)
|
||||
logging.info("Commande contextuelle 'Déplacer le message' ajoutée au CommandTree")
|
||||
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")
|
||||
|
||||
async def on_ready(self):
|
||||
logging.info(f'Connecté en tant que {self.user} (ID: {self.user.id})')
|
||||
@@ -70,16 +97,28 @@ class DiscordBot(discord.Client):
|
||||
for guild in self.guilds:
|
||||
await updateInviteCache(guild)
|
||||
|
||||
self.loop.create_task(self.updateStatus())
|
||||
self.loop.create_task(self.updateHumbleBundle())
|
||||
self.loop.create_task(self.updateYouTube())
|
||||
self.loop.create_task(self.updateFreeLoot())
|
||||
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)
|
||||
@@ -103,6 +142,11 @@ 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():
|
||||
@@ -151,6 +195,10 @@ bot = DiscordBot(intents=intents)
|
||||
async def on_message(message: Message):
|
||||
if message.author == bot.user:
|
||||
return
|
||||
|
||||
# Gestion des messages dans les auto rooms (avant le check des commandes !)
|
||||
await on_message_auto_rooms(bot, message)
|
||||
|
||||
if not message.content.startswith('!'):
|
||||
return
|
||||
command_name = message.content.split()[0]
|
||||
@@ -160,10 +208,6 @@ 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
|
||||
@@ -173,10 +217,6 @@ 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
|
||||
@@ -184,11 +224,6 @@ 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)
|
||||
@@ -214,103 +249,6 @@ 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)
|
||||
@@ -321,6 +259,7 @@ 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
|
||||
@@ -334,4 +273,3 @@ async def on_invite_create(invite):
|
||||
@bot.event
|
||||
async def on_invite_delete(invite):
|
||||
await updateInviteCache(invite.guild)
|
||||
|
||||
|
||||
+475
-76
@@ -1,10 +1,15 @@
|
||||
# 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] = {}
|
||||
@@ -16,13 +21,14 @@ _control_message_ids: dict[int, tuple[int, int]] = {}
|
||||
REACTIONS = [
|
||||
("🔓", "open", "Ouvert"),
|
||||
("🔒", "closed", "Fermé"),
|
||||
("🛡️", "private", "Privé"),
|
||||
("🔐", "private", "Privé"),
|
||||
("✅", "whitelist", "Liste blanche"),
|
||||
("🚫", "blacklist", "Liste noire"),
|
||||
("🧹", "purge", "Purge"),
|
||||
("👑", "transfer", "Propriété"),
|
||||
("🎤", "speak", "Micro"),
|
||||
("📹", "stream", "Vidéo"),
|
||||
("📊", "soundboards", "Soundboards"),
|
||||
("📝", "status", "Statut"),
|
||||
]
|
||||
|
||||
@@ -34,40 +40,85 @@ def _status_display(access_mode: str) -> str:
|
||||
if access_mode == "closed":
|
||||
return "🔒 Fermé"
|
||||
if access_mode == "private":
|
||||
return "🔒 Privé"
|
||||
return "🔐 Privé"
|
||||
return "🔓 Ouvert"
|
||||
|
||||
|
||||
def _status_emoji(access_mode: str) -> str:
|
||||
"""Emoji cadenas seul pour le nom du channel."""
|
||||
if access_mode == "private":
|
||||
return "🔐"
|
||||
return "🔓" if access_mode == "open" else "🔒"
|
||||
|
||||
|
||||
def _build_control_embed(owner: Member, voice_channel: discord.VoiceChannel, access_mode: str) -> discord.Embed:
|
||||
"""Construit l’embed de config avec infos du salon."""
|
||||
def _build_control_embed(owner: Member, voice_channel: discord.VoiceChannel, access_mode: str, room: dict = None) -> discord.Embed:
|
||||
"""Construit l'embed de config avec infos du salon."""
|
||||
embed = discord.Embed(
|
||||
title="Configuration du salon",
|
||||
title="⚙️ Configuration du salon",
|
||||
description=(
|
||||
"Voici l’espace de configuration de votre salon vocal. "
|
||||
"Utilisez les réactions ci-dessous — seul le propriétaire peut réagir."
|
||||
"Voici l'espace de configuration de votre salon vocal temporaire. "
|
||||
"Les différentes options disponibles vous permettent de personnaliser les permissions de votre salon selon vos préférences."
|
||||
),
|
||||
color=discord.Color.blurple()
|
||||
color=discord.Color.orange()
|
||||
)
|
||||
members_count = len(voice_channel.members)
|
||||
user_limit = voice_channel.user_limit or 0
|
||||
limit_text = f"{user_limit} max" if user_limit else "Illimitée"
|
||||
members_text = f"{members_count} / {user_limit}" if user_limit else str(members_count)
|
||||
bitrate_kbps = (voice_channel.bitrate or 0) // 1000
|
||||
|
||||
embed.add_field(name="Propriétaire", value=owner.mention, inline=True)
|
||||
embed.add_field(name="Statut du salon", value=_status_display(access_mode), inline=True)
|
||||
embed.add_field(name="Nom du salon", value=voice_channel.name, inline=True)
|
||||
embed.add_field(name="Membres", value=members_text, inline=True)
|
||||
embed.add_field(name="Limite", value=limit_text, inline=True)
|
||||
embed.add_field(name="Bitrate", value=f"{bitrate_kbps} kbps", inline=True)
|
||||
embed.add_field(name="Accès", value="🔓 Ouvert · 🔒 Fermé · 🛡️ Privé", inline=False)
|
||||
embed.add_field(name="Listes", value="✅ Liste blanche · 🚫 Liste noire", inline=False)
|
||||
embed.add_field(name="Actions", value="🧹 Purge · 👑 Propriété · 🎤 Micro · 📹 Vidéo · 📝 Statut", inline=False)
|
||||
|
||||
# Récupération des infos
|
||||
whitelist = room.get("whitelist", set()) if room else set()
|
||||
blacklist = room.get("blacklist", set()) if room else set()
|
||||
whitelist_text = f"{len(whitelist)} membre(s)" if whitelist else "Aucun"
|
||||
blacklist_text = f"{len(blacklist)} membre(s)" if blacklist else "Aucun"
|
||||
|
||||
# Section Propriétaire
|
||||
embed.add_field(
|
||||
name=f"👤 Propriétaire du salon : {owner.display_name}",
|
||||
value="",
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Section Modes d'accès
|
||||
mode_open = "🔓 **Ouvert**\nLe salon sera ouvert à tous les membres, sauf ceux figurant sur la liste noire."
|
||||
mode_closed = "🔒 **Fermé**\nLe salon sera visible de tous, mais seulement accessible à la liste blanche."
|
||||
mode_private = "🔐 **Privé**\nLe salon ne sera visible et accessible qu'aux membres de la liste blanche."
|
||||
|
||||
embed.add_field(name=mode_open, value="", inline=True)
|
||||
embed.add_field(name=mode_closed, value="", inline=True)
|
||||
embed.add_field(name=mode_private, value="", inline=True)
|
||||
|
||||
# Section Listes
|
||||
embed.add_field(
|
||||
name="📝 **Liste blanche**",
|
||||
value=f"Les membres présents dans cette liste pourront toujours rejoindre le salon.\n\n{whitelist_text}",
|
||||
inline=True
|
||||
)
|
||||
embed.add_field(
|
||||
name="🚫 **Liste noire**",
|
||||
value=f"Les membres présents dans cette liste ne pourront jamais rejoindre le salon.\n\n{blacklist_text}",
|
||||
inline=True
|
||||
)
|
||||
embed.add_field(name="\u200b", value="", inline=True) # Spacer
|
||||
|
||||
# Section Purge
|
||||
embed.add_field(
|
||||
name="🧹 **Purge**",
|
||||
value="Déconnecter tous les membres du salon vocal à l'exception de ceux présents dans la liste blanche.",
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Section Transfert
|
||||
embed.add_field(
|
||||
name="👑 **Transférer**",
|
||||
value="Transférer la gestion du salon au membre de votre choix.",
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Note importante
|
||||
embed.add_field(
|
||||
name="💡",
|
||||
value="Les membres de la liste blanche ne sont pas impactés par les permissions refusées aux membres.",
|
||||
inline=False
|
||||
)
|
||||
|
||||
embed.set_footer(text="Réagissez avec les émojis ci-dessous pour configurer votre salon")
|
||||
return embed
|
||||
|
||||
|
||||
@@ -76,7 +127,33 @@ def _room_key(guild_id: int, owner_id: int) -> tuple[int, int]:
|
||||
|
||||
|
||||
def _get_room(guild_id: int, owner_id: int) -> Optional[dict]:
|
||||
return _rooms.get(_room_key(guild_id, owner_id))
|
||||
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
|
||||
|
||||
|
||||
def _set_room(guild_id: int, owner_id: int, data: dict):
|
||||
@@ -84,18 +161,53 @@ 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
|
||||
|
||||
|
||||
@@ -111,14 +223,30 @@ 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):
|
||||
async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist: set, blacklist: set, room: dict):
|
||||
guild = channel.guild
|
||||
everyone = guild.default_role
|
||||
overwrites = {}
|
||||
overwrites = dict(channel.overwrites) # Récupérer les overwrites existants
|
||||
|
||||
# Préserver les permissions existantes pour everyone (stream, speak, soundboards, etc.)
|
||||
existing_everyone_ow = overwrites.get(everyone, discord.PermissionOverwrite())
|
||||
everyone_ow = discord.PermissionOverwrite()
|
||||
|
||||
# Copier les permissions importantes qui ne doivent pas être écrasées
|
||||
everyone_ow.stream = existing_everyone_ow.stream
|
||||
everyone_ow.speak = existing_everyone_ow.speak
|
||||
everyone_ow.use_soundboard = existing_everyone_ow.use_soundboard
|
||||
|
||||
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.
|
||||
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:
|
||||
overwrites.pop(target, None)
|
||||
# Ajouter les overwrites pour la blacklist
|
||||
for uid in blacklist:
|
||||
m = guild.get_member(uid)
|
||||
if m:
|
||||
@@ -126,6 +254,11 @@ async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist
|
||||
elif mode == "closed":
|
||||
everyone_ow.connect = False
|
||||
everyone_ow.view_channel = True
|
||||
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:
|
||||
overwrites.pop(target, None)
|
||||
# Ajouter les overwrites pour la whitelist
|
||||
for uid in whitelist:
|
||||
m = guild.get_member(uid)
|
||||
if m:
|
||||
@@ -133,19 +266,26 @@ async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist
|
||||
elif mode == "private":
|
||||
everyone_ow.connect = False
|
||||
everyone_ow.view_channel = False
|
||||
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:
|
||||
overwrites.pop(target, None)
|
||||
# Ajouter les overwrites pour la whitelist
|
||||
for uid in whitelist:
|
||||
m = guild.get_member(uid)
|
||||
if m:
|
||||
overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True)
|
||||
|
||||
overwrites[everyone] = everyone_ow
|
||||
await channel.edit(overwrites=overwrites)
|
||||
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):
|
||||
"""channel = salon vocal (partie texte / onglet Discussion)."""
|
||||
room = _get_room(guild_id, owner_id)
|
||||
if not room:
|
||||
await channel.send("Ce salon n’existe plus.")
|
||||
await channel.send("Ce salon n'existe plus.")
|
||||
return
|
||||
voice_channel = bot.get_channel(room["voice_channel_id"])
|
||||
if not voice_channel or not isinstance(voice_channel, discord.VoiceChannel):
|
||||
@@ -154,37 +294,36 @@ 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()))
|
||||
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(" 🔓🔒")
|
||||
base_name = voice_channel.name.rstrip(" 🔓🔒🔐")
|
||||
new_name = f"{base_name} {_status_emoji(action)}"
|
||||
await voice_channel.edit(name=new_name)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
await channel.send(f"Accès du salon défini sur **{action}**.")
|
||||
# Mettre à jour uniquement le statut (cadenas) dans le message de config
|
||||
control_message_id = room.get("control_message_id")
|
||||
if control_message_id:
|
||||
try:
|
||||
msg = await channel.fetch_message(control_message_id)
|
||||
if msg.embeds:
|
||||
embed = msg.embeds[0].copy()
|
||||
for i, f in enumerate(embed.fields):
|
||||
if f.name == "Statut du salon":
|
||||
embed.set_field_at(i, name="Statut du salon", value=_status_display(action), inline=f.inline)
|
||||
break
|
||||
else:
|
||||
embed.add_field(name="Statut du salon", value=_status_display(action), inline=False)
|
||||
await msg.edit(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
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)
|
||||
|
||||
elif action == "whitelist":
|
||||
await channel.send("Liste blanche : mentionnez un membre pour l’ajouter/retirer.")
|
||||
whitelist = room.get("whitelist", set())
|
||||
whitelist_text = ", ".join([f"<@{uid}>" for uid in whitelist]) if whitelist else "Aucun membre"
|
||||
await channel.send(
|
||||
f"**📝 Liste blanche actuelle :** {whitelist_text}\n\n"
|
||||
f"Mentionnez un membre pour l'ajouter ou le retirer de la liste blanche."
|
||||
)
|
||||
room["awaiting_whitelist"] = True
|
||||
|
||||
elif action == "blacklist":
|
||||
await channel.send("Liste noire : mentionnez un membre pour l’ajouter/retirer.")
|
||||
blacklist = room.get("blacklist", set())
|
||||
blacklist_text = ", ".join([f"<@{uid}>" for uid in blacklist]) if blacklist else "Aucun membre"
|
||||
await channel.send(
|
||||
f"**🚫 Liste noire actuelle :** {blacklist_text}\n\n"
|
||||
f"Mentionnez un membre pour l'ajouter ou le retirer de la liste noire."
|
||||
)
|
||||
room["awaiting_blacklist"] = True
|
||||
|
||||
elif action == "purge":
|
||||
whitelist = room.get("whitelist", set())
|
||||
@@ -197,30 +336,80 @@ async def _handle_reaction_action(bot: discord.Client, guild_id: int, owner_id:
|
||||
kicked += 1
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
await channel.send(f"Purge effectuée : {kicked} membre(s) déconnecté(s).")
|
||||
await channel.send(f"🧹 Purge effectuée : {kicked} membre(s) déconnecté(s).")
|
||||
|
||||
elif action == "transfer":
|
||||
await channel.send("Transférer le salon : mentionnez le membre à qui donner la propriété.")
|
||||
await channel.send(
|
||||
f"**👑 Transfert de propriété**\n\n"
|
||||
f"Mentionnez le membre à qui vous souhaitez transférer la gestion du salon."
|
||||
)
|
||||
room["awaiting_transfer"] = True
|
||||
|
||||
elif action in ("speak", "stream"):
|
||||
elif action in ("speak", "stream", "soundboards"):
|
||||
everyone = voice_channel.guild.default_role
|
||||
overwrites = dict(voice_channel.overwrites)
|
||||
ow = overwrites.get(everyone) or discord.PermissionOverwrite()
|
||||
|
||||
# BUG FIX : Par défaut, Discord autorise stream, speak et soundboards
|
||||
# Si la permission n'est pas explicitement définie (None), on considère qu'elle est True
|
||||
current = getattr(ow, action)
|
||||
setattr(ow, action, not current if current is not None else False)
|
||||
if current is None:
|
||||
# Permission non définie = autorisée par défaut dans Discord
|
||||
# On veut la désactiver lors du premier clic
|
||||
setattr(ow, action, False)
|
||||
new_value = False
|
||||
else:
|
||||
# Permission définie, on l'inverse
|
||||
setattr(ow, action, not current)
|
||||
new_value = not current
|
||||
|
||||
overwrites[everyone] = ow
|
||||
await voice_channel.edit(overwrites=overwrites)
|
||||
label = "Micro" if action == "speak" else "Vidéo"
|
||||
await channel.send(f"{label} : {'autorisé' if getattr(ow, action) else 'désactivé'} pour tous.")
|
||||
|
||||
labels = {"speak": "Micro", "stream": "Vidéo/Partage d'écran", "soundboards": "Soundboards"}
|
||||
label = labels.get(action, action.capitalize())
|
||||
await channel.send(f"{label} : {'autorisé' if new_value else 'désactivé'} pour tous.")
|
||||
|
||||
elif action == "status":
|
||||
status_text = _status_display(room.get("access_mode", "open"))
|
||||
await channel.send(f"Statut du salon : {status_text}\nRépondez avec le nouveau nom du salon pour le modifier.")
|
||||
current_status = voice_channel.status or "Aucun statut défini"
|
||||
await channel.send(
|
||||
f"**Statut actuel du salon :** {current_status}\n\n"
|
||||
f"Pour modifier le statut du salon (le texte affiché en haut du salon vocal), "
|
||||
f"répondez avec le nouveau statut (max 500 caractères).\n"
|
||||
f"💡 Pour supprimer le statut, répondez avec `clear` ou `effacer`."
|
||||
)
|
||||
room["awaiting_status"] = True
|
||||
|
||||
|
||||
async def send_control_panel(bot: discord.Client, guild_id: int, owner: Member, voice_channel: discord.VoiceChannel) -> Optional[int]:
|
||||
"""Envoie le message de config avec réactions dans la partie texte du salon vocal (onglet Discussion). Seul le proprio peut réagir. Retourne l’id du message."""
|
||||
embed = _build_control_embed(owner, voice_channel, "open")
|
||||
async def _update_control_panel(bot: discord.Client, guild_id: int, owner_id: int, channel):
|
||||
"""Met à jour le panneau de contrôle avec les nouvelles informations."""
|
||||
room = _get_room(guild_id, owner_id)
|
||||
if not room:
|
||||
return
|
||||
|
||||
control_message_id = room.get("control_message_id")
|
||||
if not control_message_id:
|
||||
return
|
||||
|
||||
voice_channel = bot.get_channel(room["voice_channel_id"])
|
||||
if not voice_channel or not isinstance(voice_channel, discord.VoiceChannel):
|
||||
return
|
||||
|
||||
owner = voice_channel.guild.get_member(owner_id)
|
||||
if not owner:
|
||||
return
|
||||
|
||||
try:
|
||||
msg = await channel.fetch_message(control_message_id)
|
||||
embed = _build_control_embed(owner, voice_channel, room.get("access_mode", "open"), room)
|
||||
await msg.edit(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
|
||||
async def send_control_panel(bot: discord.Client, guild_id: int, owner: Member, voice_channel: discord.VoiceChannel, room: dict) -> Optional[int]:
|
||||
"""Envoie le message de config avec réactions dans la partie texte du salon vocal (onglet Discussion). Seul le proprio peut réagir. Retourne l'id du message."""
|
||||
embed = _build_control_embed(owner, voice_channel, "open", room)
|
||||
|
||||
try:
|
||||
# Message dans la partie texte du vocal (onglet Discussion à droite)
|
||||
@@ -229,23 +418,106 @@ async def send_control_panel(bot: discord.Client, guild_id: int, owner: Member,
|
||||
await msg.add_reaction(emoji)
|
||||
return msg.id
|
||||
except discord.HTTPException as e:
|
||||
logging.error(f"Impossible d’envoyer le panneau Auto Room dans le vocal : {e}")
|
||||
logging.error(f"Impossible d'envoyer le panneau Auto Room dans le vocal : {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member, before: VoiceState, after: VoiceState):
|
||||
config = ConfigurationHelper()
|
||||
if not config.getValue("auto_rooms_enable"):
|
||||
_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:
|
||||
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
|
||||
# Nom du salon avec statut (cadenas) à la création
|
||||
channel_name = f"Salon de {member.display_name} {_status_emoji('open')}"
|
||||
try:
|
||||
new_channel = await guild.create_voice_channel(
|
||||
@@ -254,31 +526,43 @@ async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member,
|
||||
reason="Auto room"
|
||||
)
|
||||
await member.move_to(new_channel)
|
||||
control_message_id = await send_control_panel(bot, guild.id, member, new_channel)
|
||||
_set_room(guild.id, member.id, {
|
||||
|
||||
# Créer la room data d'abord
|
||||
room_data = {
|
||||
"guild_id": guild.id,
|
||||
"voice_channel_id": new_channel.id,
|
||||
"control_message_id": control_message_id,
|
||||
"control_message_id": None, # Sera mis à jour après
|
||||
"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)
|
||||
|
||||
logging.info(f"Auto room créé : {new_channel.name} pour {member.display_name}")
|
||||
except discord.HTTPException as e:
|
||||
logging.error(f"Erreur création auto room : {e}")
|
||||
|
||||
if before.channel and before.channel.id != trigger_channel_id:
|
||||
if before.channel and before.channel != after.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:
|
||||
if member.id == owner_id and remaining:
|
||||
new_owner = remaining[0]
|
||||
_del_room(guild.id, owner_id)
|
||||
try:
|
||||
await before.channel.delete(reason="Propriétaire parti (auto room)")
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
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)
|
||||
elif len(remaining) == 0:
|
||||
_del_room(guild.id, owner_id)
|
||||
try:
|
||||
@@ -287,11 +571,126 @@ async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member,
|
||||
pass
|
||||
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
# Vérifier si c'est dans un salon vocal (partie texte)
|
||||
if not isinstance(message.channel, discord.VoiceChannel):
|
||||
return
|
||||
|
||||
# Trouver si c'est une auto room
|
||||
result = _find_room_by_channel(message.guild.id, message.channel.id)
|
||||
if not result:
|
||||
return
|
||||
|
||||
owner_id, room = result
|
||||
|
||||
# Seul le propriétaire peut interagir
|
||||
if message.author.id != owner_id:
|
||||
return
|
||||
|
||||
voice_channel = message.channel
|
||||
|
||||
# Gestion du statut de salon
|
||||
if room.get("awaiting_status"):
|
||||
room["awaiting_status"] = False
|
||||
new_status = message.content.strip()
|
||||
|
||||
try:
|
||||
if new_status.lower() in ("clear", "effacer", "supprimer", "delete"):
|
||||
await voice_channel.edit(status=None)
|
||||
await message.channel.send("✅ Le statut du salon a été supprimé.")
|
||||
elif len(new_status) > 500:
|
||||
await message.channel.send("❌ Le statut ne peut pas dépasser 500 caractères.")
|
||||
room["awaiting_status"] = True # Réessayer
|
||||
else:
|
||||
await voice_channel.edit(status=new_status)
|
||||
await message.channel.send(f"✅ Le statut du salon a été mis à jour : **{new_status}**")
|
||||
except discord.HTTPException as e:
|
||||
await message.channel.send(f"❌ Erreur lors de la modification du statut : {e}")
|
||||
return
|
||||
|
||||
# Gestion de la liste blanche (si en attente)
|
||||
if room.get("awaiting_whitelist"):
|
||||
room["awaiting_whitelist"] = False
|
||||
if message.mentions:
|
||||
target = message.mentions[0]
|
||||
whitelist = room.get("whitelist", set())
|
||||
if target.id in whitelist:
|
||||
whitelist.remove(target.id)
|
||||
await message.channel.send(f"✅ {target.mention} a été retiré de la liste blanche.")
|
||||
else:
|
||||
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 _update_control_panel(bot, message.guild.id, owner_id, message.channel)
|
||||
return
|
||||
|
||||
# Gestion de la liste noire (si en attente)
|
||||
if room.get("awaiting_blacklist"):
|
||||
room["awaiting_blacklist"] = False
|
||||
if message.mentions:
|
||||
target = message.mentions[0]
|
||||
blacklist = room.get("blacklist", set())
|
||||
if target.id in blacklist:
|
||||
blacklist.remove(target.id)
|
||||
await message.channel.send(f"✅ {target.mention} a été retiré de la liste noire.")
|
||||
else:
|
||||
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 _update_control_panel(bot, message.guild.id, owner_id, message.channel)
|
||||
return
|
||||
|
||||
# Gestion du transfert de propriété (si en attente)
|
||||
if room.get("awaiting_transfer"):
|
||||
room["awaiting_transfer"] = False
|
||||
if message.mentions:
|
||||
new_owner = message.mentions[0]
|
||||
if new_owner.id == owner_id:
|
||||
await message.channel.send("❌ Vous êtes déjà le propriétaire du salon.")
|
||||
return
|
||||
|
||||
# Transférer la propriété
|
||||
old_owner_id = owner_id
|
||||
_del_room(message.guild.id, old_owner_id)
|
||||
room["owner_id"] = new_owner.id
|
||||
_set_room(message.guild.id, new_owner.id, room)
|
||||
|
||||
# Renommer le salon
|
||||
try:
|
||||
base_name = f"Salon de {new_owner.display_name}"
|
||||
new_name = f"{base_name} {_status_emoji(room.get('access_mode', 'open'))}"
|
||||
await voice_channel.edit(name=new_name)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
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)
|
||||
return
|
||||
|
||||
|
||||
async def on_raw_reaction_add_auto_rooms(bot: discord.Client, payload: discord.RawReactionActionEvent):
|
||||
"""Seul le propriétaire peut réagir ; on retire la réaction des autres."""
|
||||
if payload.user_id == bot.user.id:
|
||||
return
|
||||
if not ConfigurationHelper().getValue("auto_rooms_enable"):
|
||||
enabled, _ = _auto_rooms_config()
|
||||
if not enabled:
|
||||
return
|
||||
room_info = _find_room_by_message(payload.message_id)
|
||||
if not room_info:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# FreeLoot Discord : notifications depuis le feed LootScraper (jeux gratuits Epic, Amazon Prime, GOG, etc.)
|
||||
# FreeLoot Discord : notifications depuis les flux LootScraper (Epic, Amazon Prime, GOG, Steam, etc.)
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
@@ -56,6 +56,7 @@ 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")
|
||||
|
||||
@@ -69,6 +70,7 @@ 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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"@here **Humble Bundle** propose un pack de jeu [{bundle['name']}]({bundle['url']}) contenant :\n"
|
||||
message = f"**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}."
|
||||
|
||||
+691
-360
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,282 @@
|
||||
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' ', ' ', text)
|
||||
text = re.sub(r'&', '&', text)
|
||||
text = re.sub(r'<', '<', text)
|
||||
text = re.sub(r'>', '>', 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))
|
||||
@@ -0,0 +1,123 @@
|
||||
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 d’un 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")
|
||||
@@ -0,0 +1,218 @@
|
||||
# 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)
|
||||
+170
-68
@@ -2,6 +2,7 @@ import logging
|
||||
import asyncio
|
||||
import xml.etree.ElementTree as ET
|
||||
import requests
|
||||
import discord
|
||||
|
||||
from database import db
|
||||
from database.models import YouTubeNotification
|
||||
@@ -24,14 +25,32 @@ 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):
|
||||
@@ -84,64 +103,93 @@ 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 = {
|
||||
'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, {
|
||||
'title': video_title,
|
||||
'url': video_url,
|
||||
'published': published_at,
|
||||
'channel_name': channel_name,
|
||||
'thumbnail': thumbnail,
|
||||
'is_short': is_short
|
||||
}))
|
||||
videos.append((video_id, video_data))
|
||||
elif notification.video_type == 'short' and is_short:
|
||||
videos.append((video_id, {
|
||||
'title': video_title,
|
||||
'url': video_url,
|
||||
'published': published_at,
|
||||
'channel_name': channel_name,
|
||||
'thumbnail': thumbnail,
|
||||
'is_short': is_short
|
||||
}))
|
||||
videos.append((video_id, video_data))
|
||||
elif notification.video_type == 'video' and not is_short:
|
||||
videos.append((video_id, {
|
||||
'title': video_title,
|
||||
'url': video_url,
|
||||
'published': published_at,
|
||||
'channel_name': channel_name,
|
||||
'thumbnail': thumbnail,
|
||||
'is_short': is_short
|
||||
}))
|
||||
videos.append((video_id, video_data))
|
||||
|
||||
videos.sort(key=lambda x: x[1]['published'], reverse=True)
|
||||
|
||||
if videos:
|
||||
latest_video_id, latest_video = videos[0]
|
||||
|
||||
# Si c'est la première vérification après démarrage, on synchronise sans notifier
|
||||
if is_first_check:
|
||||
if not notification.last_video_id or notification.last_video_id != latest_video_id:
|
||||
logger.info(f"YouTube: synchronisation initiale pour {channel_id}, dernière vidéo: {latest_video_id}")
|
||||
notification.last_video_id = latest_video_id
|
||||
db.session.commit()
|
||||
return
|
||||
|
||||
# Vérifications normales ensuite
|
||||
if not notification.last_video_id:
|
||||
notification.last_video_id = latest_video_id
|
||||
db.session.commit()
|
||||
return
|
||||
|
||||
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()
|
||||
# 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 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.
|
||||
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:
|
||||
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)
|
||||
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()
|
||||
|
||||
|
||||
async def _notifyVideo(notification: YouTubeNotification, video_data: dict, video_id: str):
|
||||
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."""
|
||||
from discordbot import bot
|
||||
try:
|
||||
channel_name = video_data.get('channel_name', 'Inconnu')
|
||||
@@ -151,8 +199,9 @@ async def _notifyVideo(notification: YouTubeNotification, video_data: dict, vide
|
||||
published_at = video_data.get('published', '')
|
||||
is_short = video_data.get('is_short', False)
|
||||
|
||||
message_template = embed_config.get('message_template', '')
|
||||
try:
|
||||
message = notification.message.format(
|
||||
message = message_template.format(
|
||||
channel_name=channel_name or 'Inconnu',
|
||||
video_title=video_title or 'Sans titre',
|
||||
video_url=video_url,
|
||||
@@ -161,15 +210,16 @@ async def _notifyVideo(notification: YouTubeNotification, video_data: dict, vide
|
||||
published_at=published_at or '',
|
||||
is_short=is_short
|
||||
)
|
||||
except KeyError as e:
|
||||
logger.error(f"Variable manquante dans le message de notification: {e}")
|
||||
except (KeyError, AttributeError, ValueError) as e:
|
||||
logger.error(f"Erreur de formatage du message: {e}")
|
||||
message = f"🎥 Nouvelle vidéo de {channel_name}: [{video_title}]({video_url})"
|
||||
|
||||
logger.info(f"Envoi de notification YouTube: {message}")
|
||||
bot.loop.create_task(_sendMessage(notification, message, video_url, thumbnail, video_title, channel_name, video_id, published_at, is_short))
|
||||
return await _sendMessage(embed_config, 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:
|
||||
@@ -190,26 +240,29 @@ def _format_embed_text(text: str, channel_name: str, video_title: str, video_url
|
||||
return text
|
||||
|
||||
|
||||
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):
|
||||
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."""
|
||||
from discordbot import bot
|
||||
try:
|
||||
discord_channel = bot.get_channel(notification.notify_channel)
|
||||
channel_id = int(embed_config['notify_channel'])
|
||||
discord_channel = bot.get_channel(channel_id)
|
||||
if not discord_channel:
|
||||
logger.error(f"Canal Discord {notification.notify_channel} introuvable")
|
||||
return
|
||||
# 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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
try:
|
||||
embed_color = int(notification.embed_color or 'FF0000', 16)
|
||||
embed_color = int(embed_config['embed_color'], 16)
|
||||
except ValueError:
|
||||
embed_color = 0xFF0000
|
||||
|
||||
embed = discord.Embed(
|
||||
title=embed_title,
|
||||
title=embed_title_text,
|
||||
url=video_url,
|
||||
color=embed_color
|
||||
)
|
||||
@@ -217,18 +270,19 @@ async def _sendMessage(notification: YouTubeNotification, message: str, video_ur
|
||||
if embed_description:
|
||||
embed.description = embed_description
|
||||
|
||||
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"
|
||||
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"
|
||||
embed.set_author(name=author_name, icon_url=author_icon)
|
||||
|
||||
if notification.embed_thumbnail and thumbnail:
|
||||
if embed_config['embed_thumbnail'] and thumbnail:
|
||||
embed.set_thumbnail(url=thumbnail)
|
||||
|
||||
if notification.embed_image and thumbnail:
|
||||
if embed_config['embed_image'] and thumbnail:
|
||||
embed.set_image(url=thumbnail)
|
||||
|
||||
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 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 footer_text:
|
||||
embed.set_footer(text=footer_text)
|
||||
|
||||
@@ -237,6 +291,54 @@ async def _sendMessage(notification: YouTubeNotification, message: str, video_ur
|
||||
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))
|
||||
|
||||
+18
-4
@@ -6,6 +6,8 @@ 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 = [
|
||||
@@ -16,6 +18,7 @@ SOURCES = [
|
||||
("gog", "GOG", "🎮"),
|
||||
("google_play", "Google Play", "🤖"),
|
||||
("apple_app_store", "Apple App Store", "🍎"),
|
||||
("steam", "Steam", "🎮"),
|
||||
]
|
||||
|
||||
|
||||
@@ -37,6 +40,8 @@ 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
|
||||
|
||||
|
||||
@@ -51,6 +56,7 @@ 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())
|
||||
@@ -135,10 +141,10 @@ def extract_rating_from_content(content: str) -> str | None:
|
||||
return raw if len(raw) > 0 and len(raw) < 200 else None
|
||||
|
||||
|
||||
def fetch_feed() -> list[dict] | None:
|
||||
"""Récupère et parse le flux Atom, retourne une liste d'entrées brutes."""
|
||||
def _fetch_single_feed(feed_url: str) -> list[dict]:
|
||||
"""Récupère et parse un flux Atom LootScraper."""
|
||||
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 = []
|
||||
@@ -168,7 +174,15 @@ def fetch_feed() -> list[dict] | None:
|
||||
})
|
||||
return entries
|
||||
except Exception:
|
||||
return None
|
||||
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
|
||||
|
||||
|
||||
def get_display_entries() -> list[dict]:
|
||||
|
||||
+95
-3
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from twitchAPI.twitch import Twitch
|
||||
from twitchAPI.type import AuthScope, ChatEvent
|
||||
@@ -44,6 +45,7 @@ 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
|
||||
@@ -70,6 +72,13 @@ 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,
|
||||
@@ -104,11 +113,45 @@ async def _handleCustomCommand(msg: ChatMessage):
|
||||
if commande:
|
||||
permission = commande.twitch_permission or 'viewer'
|
||||
if not _user_has_twitch_permission(msg, permission):
|
||||
return # Pas de réponse = l'utilisateur n'a pas la permission
|
||||
response = commande.response.replace('{user}', msg.user.name)
|
||||
return
|
||||
response = _replace_command_variables(commande.response, msg)
|
||||
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}')
|
||||
|
||||
@@ -124,6 +167,7 @@ def _isConfigured():
|
||||
|
||||
class TwitchBot():
|
||||
_eventsub = None
|
||||
_loop = None
|
||||
|
||||
async def _connect(self):
|
||||
with webapp.app_context():
|
||||
@@ -133,13 +177,41 @@ 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é")
|
||||
|
||||
@@ -163,6 +235,9 @@ 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)
|
||||
@@ -184,6 +259,8 @@ 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:
|
||||
@@ -251,7 +328,22 @@ class TwitchBot():
|
||||
await asyncio.sleep(120)
|
||||
|
||||
def begin(self):
|
||||
asyncio.run(self._connect())
|
||||
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()
|
||||
|
||||
@@ -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_messages(broadcaster_id, moderator_id, message_id=msg.id)
|
||||
await twitch.delete_chat_message(broadcaster_id, moderator_id, message_id=msg.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur suppression message: {e}")
|
||||
|
||||
|
||||
+66
-10
@@ -1,4 +1,6 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import discord
|
||||
|
||||
from twitchAPI.twitch import Twitch
|
||||
@@ -47,7 +49,15 @@ async def checkOnlineStreamer(twitch: Twitch) :
|
||||
global _live_alert_first_check
|
||||
with webapp.app_context() :
|
||||
alerts : list[LiveAlert] = LiveAlert.query.all()
|
||||
streams = await _retreiveStreams(twitch, alerts)
|
||||
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é)
|
||||
@@ -59,17 +69,43 @@ async def checkOnlineStreamer(twitch: Twitch) :
|
||||
|
||||
# Mise à jour du BOT_STATUS pour la webapp
|
||||
if main_stream:
|
||||
webapp.config["BOT_STATUS"]["twitch_is_live"] = True
|
||||
webapp.config["BOT_STATUS"]["twitch_viewer_count"] = getattr(main_stream, 'viewer_count', 0)
|
||||
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
|
||||
else:
|
||||
webapp.config["BOT_STATUS"]["twitch_is_live"] = False
|
||||
webapp.config["BOT_STATUS"]["twitch_viewer_count"] = 0
|
||||
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
|
||||
|
||||
# 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 == alert.login), None)
|
||||
stream = next((s for s in streams if s.user_login.lower() == (alert.login or '').lower()), None)
|
||||
if stream:
|
||||
alert.online = True
|
||||
if alert.watch_activity and alert.enable:
|
||||
@@ -83,7 +119,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 == alert.login), None)
|
||||
stream = next((s for s in streams if s.user_login.lower() == (alert.login or '').lower()), None)
|
||||
if stream :
|
||||
logger.info(f'Streamer en ligne : {alert.login}')
|
||||
if not alert.online and alert.enable :
|
||||
@@ -101,15 +137,27 @@ 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}'
|
||||
)
|
||||
await bot.change_presence(status=discord.Status.online, activity=activity)
|
||||
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)
|
||||
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
|
||||
@@ -117,10 +165,18 @@ async def _updateBotActivity(stream: Stream | None):
|
||||
if humeurs:
|
||||
humeur = random.choice(humeurs)
|
||||
logger.info(f'Réinitialisation du statut : {humeur.text}')
|
||||
await bot.change_presence(status=discord.Status.online, activity=discord.CustomActivity(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)
|
||||
else:
|
||||
# Si pas de humeur, remettre un statut par défaut
|
||||
await bot.change_presence(status=discord.Status.online, activity=None)
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
bot.change_presence(status=discord.Status.online, activity=None),
|
||||
bot.loop
|
||||
)
|
||||
future.result(timeout=10)
|
||||
|
||||
async def _notifyAlert(alert: LiveAlert, stream: Stream):
|
||||
stream_url = f'https://www.twitch.tv/{stream.user_login}'
|
||||
|
||||
@@ -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.delete_chat_messages(broadcaster_id, moderator_id, user_id=user_id)
|
||||
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason="Purge messages", duration=1)
|
||||
_log_action("clean", msg.user.name, viewer)
|
||||
logger.info(f'Messages de {viewer} supprimés par {msg.user.name}')
|
||||
else:
|
||||
await twitch.delete_chat_messages(broadcaster_id, moderator_id)
|
||||
await twitch.delete_chat_message(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_messages(broadcaster_id, moderator_id, message_id=msg.id)
|
||||
await twitch.delete_chat_message(broadcaster_id, moderator_id, message_id=msg.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur suppression message mot interdit: {e}")
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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)
|
||||
+10
-1
@@ -11,11 +11,20 @@ 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()
|
||||
@@ -32,7 +41,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
|
||||
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 flask import request, redirect, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
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():
|
||||
@@ -16,23 +41,40 @@ 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'
|
||||
'twitch_commands_enable': 'twitch_channel',
|
||||
'rules_ack_enable': 'rules_channel_id',
|
||||
}
|
||||
|
||||
# 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 staff_roles:
|
||||
ConfigurationHelper().createOrUpdate('moderation_staff_role_ids', ','.join(staff_roles))
|
||||
else:
|
||||
ConfigurationHelper().createOrUpdate('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 == 'moderation_staff_role_ids':
|
||||
if key in SKIP_FORM_KEYS:
|
||||
continue
|
||||
if request.form.get('rules_ack_section_in_form') and key in RULES_FORM_KEYS:
|
||||
continue
|
||||
value = request.form.get(key)
|
||||
if value and value.strip():
|
||||
@@ -45,3 +87,18 @@ 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"))
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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}))
|
||||
@@ -73,6 +73,90 @@
|
||||
</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>
|
||||
|
||||
@@ -159,6 +243,9 @@
|
||||
|
||||
<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 %}
|
||||
@@ -209,6 +296,14 @@
|
||||
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">
|
||||
|
||||
@@ -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, Google Play, Apple App Store) via le flux
|
||||
Notifications des jeux gratuits (Epic Games, Amazon Prime, GOG, Steam, Google Play, Apple App Store) via les 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>
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
{% 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 • 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 %}
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if configuration.getValue('proton_db_enable_enable') %}
|
||||
{% if configuration.getValue('proton_db_enable_enable') or configuration.getValue('proton_db_twitch_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,9 +97,46 @@
|
||||
{% 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
|
||||
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>)
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
<!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">></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, "\\'") + '\')"><' + esc(item.author) + '></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>
|
||||
@@ -106,11 +106,15 @@
|
||||
<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="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
|
||||
ProtonDB
|
||||
</a>
|
||||
<a href="{{ url_for('openFreeLoot') }}" 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">
|
||||
<span class="text-lg">🎁</span>
|
||||
FreeLoot
|
||||
</a>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
|
||||
<a href="{{ url_for('openFreeLoot') }}" 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">
|
||||
<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>
|
||||
Commandes
|
||||
@@ -249,11 +253,15 @@
|
||||
<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="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
|
||||
ProtonDB
|
||||
</a>
|
||||
<a href="{{ url_for('openFreeLoot') }}" 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">
|
||||
<span class="text-lg">🎁</span>
|
||||
FreeLoot
|
||||
</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">
|
||||
<a href="{{ url_for('openFreeLoot') }}" 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">
|
||||
<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
|
||||
</a>
|
||||
|
||||
+1079
-648
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
{% 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 %}
|
||||
@@ -16,12 +16,16 @@
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -218,7 +222,7 @@
|
||||
{{ 'Enregistrer' if notification else 'Ajouter la notification' }}
|
||||
</button>
|
||||
{% if notification %}
|
||||
<a href="{{ url_for('youtube') }}"
|
||||
<a href="{{ url_for('openYouTube') }}"
|
||||
class="px-6 py-2.5 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">
|
||||
Annuler
|
||||
</a>
|
||||
|
||||
+401
-128
@@ -2,9 +2,34 @@ 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
|
||||
from database.models import Commande, TwitchModerationLog, TwitchLinkFilter, TwitchBannedWord, ModShoutboxMessage
|
||||
from flask_login import current_user
|
||||
from database.helpers import ConfigurationHelper
|
||||
from datetime import datetime, timedelta
|
||||
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
|
||||
import asyncio
|
||||
|
||||
MODERATION_COMMANDS = [
|
||||
@@ -135,7 +160,11 @@ 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",
|
||||
commands=MODERATION_COMMANDS,
|
||||
@@ -148,6 +177,10 @@ 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")
|
||||
@@ -182,6 +215,52 @@ 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():
|
||||
@@ -234,24 +313,18 @@ 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)
|
||||
|
||||
# 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})
|
||||
else:
|
||||
return jsonify({"success": False, "error": f"Erreur: {result}"}), 500
|
||||
await twitchBot.chat.send_message(channel, message)
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(send_msg(), twitchBot._loop)
|
||||
future.result(timeout=10)
|
||||
return jsonify({"success": True})
|
||||
except TimeoutError:
|
||||
return jsonify({"success": False, "error": "Timeout lors de l'envoi"}), 504
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@@ -259,8 +332,82 @@ def send_twitch_message():
|
||||
@require_page("twitch_moderation")
|
||||
def get_twitch_messages():
|
||||
"""Retourne les derniers messages du chat Twitch"""
|
||||
messages = webapp.config["BOT_STATUS"].get("twitch_chat_messages", [])
|
||||
return jsonify({"messages": messages})
|
||||
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(),
|
||||
})
|
||||
|
||||
|
||||
@webapp.route("/twitch-moderation/execute-action", methods=['POST'])
|
||||
@require_page("twitch_moderation")
|
||||
@@ -290,114 +437,240 @@ def execute_moderation_action():
|
||||
from twitchAPI.chat import ChatMessage
|
||||
from types import SimpleNamespace
|
||||
|
||||
# Exécuter l'action de manière asynchrone
|
||||
admin_name = f"WebApp ({current_user.username})"
|
||||
|
||||
if not twitchBot._loop:
|
||||
return jsonify({"success": False, "error": "Event loop du bot non disponible"}), 503
|
||||
|
||||
async def execute_action():
|
||||
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))
|
||||
reason = params.get('reason', 'Timeout')
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
|
||||
moderator_id = await _get_moderator_id(twitchBot.twitch)
|
||||
user_id = await _get_user_id(twitchBot.twitch, username)
|
||||
|
||||
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}")
|
||||
return {"success": True, "message": f"Timeout de {username} pour {duration}s"}
|
||||
return {"success": False, "error": f"Utilisateur {username} introuvable"}
|
||||
|
||||
elif action == 'ban':
|
||||
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
|
||||
username = params.get('username', '').strip().lstrip('@')
|
||||
reason = params.get('reason', 'Ban')
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
|
||||
moderator_id = await _get_moderator_id(twitchBot.twitch)
|
||||
user_id = await _get_user_id(twitchBot.twitch, username)
|
||||
|
||||
if user_id:
|
||||
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason)
|
||||
_log_action("ban", admin_name, username, reason)
|
||||
return {"success": True, "message": f"Ban de {username}"}
|
||||
return {"success": False, "error": f"Utilisateur {username} introuvable"}
|
||||
|
||||
elif action == 'clean':
|
||||
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
|
||||
username = params.get('username', '').strip().lstrip('@')
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
|
||||
moderator_id = await _get_moderator_id(twitchBot.twitch)
|
||||
|
||||
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)
|
||||
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")
|
||||
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))
|
||||
|
||||
expires_at = datetime.now() + timedelta(seconds=duration)
|
||||
|
||||
with webapp.app_context():
|
||||
existing = TwitchPermit.query.filter_by(username=username).first()
|
||||
if existing:
|
||||
existing.expires_at = expires_at
|
||||
else:
|
||||
permit = TwitchPermit(username=username, expires_at=expires_at)
|
||||
db.session.add(permit)
|
||||
db.session.commit()
|
||||
|
||||
return {"success": True, "message": f"Permit accordé à {username} pour {duration//60}min"}
|
||||
|
||||
elif action in ['subon', 'suboff', 'emoteon', 'emoteoff']:
|
||||
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _log_action
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
|
||||
moderator_id = await _get_moderator_id(twitchBot.twitch)
|
||||
|
||||
if action == 'subon':
|
||||
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=True)
|
||||
_log_action("subon", admin_name)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
return {"success": True, "message": "Mode emote désactivé"}
|
||||
|
||||
return {"success": False, "error": f"Action '{action}' non reconnue"}
|
||||
|
||||
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)) # en secondes
|
||||
reason = params.get('reason', 'Timeout')
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
|
||||
moderator_id = await _get_moderator_id(twitchBot.twitch)
|
||||
user_id = await _get_user_id(twitchBot.twitch, username)
|
||||
|
||||
if user_id:
|
||||
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason, duration=duration)
|
||||
_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"}
|
||||
|
||||
elif action == 'ban':
|
||||
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
|
||||
username = params.get('username', '').strip().lstrip('@')
|
||||
reason = params.get('reason', 'Ban')
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
|
||||
moderator_id = await _get_moderator_id(twitchBot.twitch)
|
||||
user_id = await _get_user_id(twitchBot.twitch, username)
|
||||
|
||||
if user_id:
|
||||
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason)
|
||||
_log_action("ban", "WebApp", username, reason)
|
||||
return {"success": True, "message": f"Ban de {username}"}
|
||||
return {"success": False, "error": f"Utilisateur {username} introuvable"}
|
||||
|
||||
elif action == 'clean':
|
||||
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
|
||||
username = params.get('username', '').strip().lstrip('@')
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
|
||||
moderator_id = await _get_moderator_id(twitchBot.twitch)
|
||||
|
||||
if username:
|
||||
user_id = await _get_user_id(twitchBot.twitch, username)
|
||||
if user_id:
|
||||
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_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)) # en secondes
|
||||
|
||||
expires_at = datetime.now() + timedelta(seconds=duration)
|
||||
|
||||
with webapp.app_context():
|
||||
existing = TwitchPermit.query.filter_by(username=username).first()
|
||||
if existing:
|
||||
existing.expires_at = expires_at
|
||||
else:
|
||||
permit = TwitchPermit(username=username, expires_at=expires_at)
|
||||
db.session.add(permit)
|
||||
db.session.commit()
|
||||
|
||||
return {"success": True, "message": f"Permit accordé à {username} pour {duration//60}min"}
|
||||
|
||||
elif action in ['subon', 'suboff', 'emoteon', 'emoteoff']:
|
||||
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _log_action
|
||||
|
||||
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
|
||||
moderator_id = await _get_moderator_id(twitchBot.twitch)
|
||||
|
||||
if action == 'subon':
|
||||
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=True)
|
||||
_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", "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", "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", "WebApp")
|
||||
return {"success": True, "message": "Mode emote désactivé"}
|
||||
|
||||
return {"success": False, "error": f"Action '{action}' non reconnue"}
|
||||
|
||||
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()
|
||||
|
||||
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 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")
|
||||
|
||||
+61
-3
@@ -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
|
||||
from database.models import YouTubeNotification, YouTubeVideoHistory
|
||||
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 None,
|
||||
embed_author_icon=(request.form.get('embed_author_icon') or '').strip() 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 None
|
||||
notification.embed_author_icon = (request.form.get('embed_author_icon') or '').strip() or None
|
||||
notification.embed_thumbnail = request.form.get('embed_thumbnail') == 'on'
|
||||
notification.embed_image = request.form.get('embed_image') == 'on'
|
||||
db.session.commit()
|
||||
@@ -190,6 +190,64 @@ 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}))
|
||||
|
||||
Reference in New Issue
Block a user