Nouvelles fonctionnalités : - Système de modération Twitch complet (bans, timeouts, avertissements) - Filtre de liens intelligent pour Twitch avec whitelist/blacklist - Notifications d'événements Twitch (follows, subs, raids, etc.) - Système Freeloot Discord avec flux RSS dédié - Salons automatiques Discord (création/suppression dynamique) - Authentification utilisateur pour l'interface web (login/register) - Gestion des utilisateurs et permissions - Interface de modération Twitch dans la webapp - Interface de gestion des événements Twitch - Configuration des paramètres utilisateur - Migration BDD pour les mots bannis Améliorations de l'interface : - Refonte complète des templates (configurations, commandes, humeurs, etc.) - Nouvelle page de settings utilisateur - Page de gestion des utilisateurs (admin) - Page d'erreur 403 personnalisée - Amélioration de la navigation et du design global - Intégration de nouvelles sections dans le menu principal Modifications techniques : - Ajout de nouveaux modèles en base de données - Extension des helpers database - Mise à jour des dépendances (requirements.txt) - Amélioration de la gestion des annonces Twitch - Refactorisation du code pour meilleure maintenabilité Co-authored-by: Cursor <cursoragent@cursor.com>
333 lines
12 KiB
Python
333 lines
12 KiB
Python
# discordbot/auto_rooms.py — Auto rooms : message et réactions dans la partie texte du salon vocal (onglet Discussion)
|
||
import logging
|
||
from typing import Optional
|
||
|
||
import discord
|
||
from discord import Member, VoiceState
|
||
from database.helpers import ConfigurationHelper
|
||
|
||
# (guild_id, owner_id) -> room_data (voice_channel_id, control_message_id, whitelist, blacklist, access_mode)
|
||
_rooms: dict[tuple[int, int], dict] = {}
|
||
|
||
# message_id -> (guild_id, owner_id) pour retrouver la room depuis une réaction
|
||
_control_message_ids: dict[int, tuple[int, int]] = {}
|
||
|
||
# Emoji -> action
|
||
REACTIONS = [
|
||
("🔓", "open", "Ouvert"),
|
||
("🔒", "closed", "Fermé"),
|
||
("🛡️", "private", "Privé"),
|
||
("✅", "whitelist", "Liste blanche"),
|
||
("🚫", "blacklist", "Liste noire"),
|
||
("🧹", "purge", "Purge"),
|
||
("👑", "transfer", "Propriété"),
|
||
("🎤", "speak", "Micro"),
|
||
("📹", "stream", "Vidéo"),
|
||
("📝", "status", "Statut"),
|
||
]
|
||
|
||
|
||
def _status_display(access_mode: str) -> str:
|
||
"""Cadenas ouvert ou fermé selon si le salon est ouvert ou pas."""
|
||
if access_mode == "open":
|
||
return "🔓 Ouvert"
|
||
if access_mode == "closed":
|
||
return "🔒 Fermé"
|
||
if access_mode == "private":
|
||
return "🔒 Privé"
|
||
return "🔓 Ouvert"
|
||
|
||
|
||
def _status_emoji(access_mode: str) -> str:
|
||
"""Emoji cadenas seul pour le nom du channel."""
|
||
return "🔓" if access_mode == "open" else "🔒"
|
||
|
||
|
||
def _build_control_embed(owner: Member, voice_channel: discord.VoiceChannel, access_mode: str) -> discord.Embed:
|
||
"""Construit l’embed de config avec infos du salon."""
|
||
embed = discord.Embed(
|
||
title="Configuration du salon",
|
||
description=(
|
||
"Voici l’espace de configuration de votre salon vocal. "
|
||
"Utilisez les réactions ci-dessous — seul le propriétaire peut réagir."
|
||
),
|
||
color=discord.Color.blurple()
|
||
)
|
||
members_count = len(voice_channel.members)
|
||
user_limit = voice_channel.user_limit or 0
|
||
limit_text = f"{user_limit} max" if user_limit else "Illimitée"
|
||
members_text = f"{members_count} / {user_limit}" if user_limit else str(members_count)
|
||
bitrate_kbps = (voice_channel.bitrate or 0) // 1000
|
||
|
||
embed.add_field(name="Propriétaire", value=owner.mention, inline=True)
|
||
embed.add_field(name="Statut du salon", value=_status_display(access_mode), inline=True)
|
||
embed.add_field(name="Nom du salon", value=voice_channel.name, inline=True)
|
||
embed.add_field(name="Membres", value=members_text, inline=True)
|
||
embed.add_field(name="Limite", value=limit_text, inline=True)
|
||
embed.add_field(name="Bitrate", value=f"{bitrate_kbps} kbps", inline=True)
|
||
embed.add_field(name="Accès", value="🔓 Ouvert · 🔒 Fermé · 🛡️ Privé", inline=False)
|
||
embed.add_field(name="Listes", value="✅ Liste blanche · 🚫 Liste noire", inline=False)
|
||
embed.add_field(name="Actions", value="🧹 Purge · 👑 Propriété · 🎤 Micro · 📹 Vidéo · 📝 Statut", inline=False)
|
||
return embed
|
||
|
||
|
||
def _room_key(guild_id: int, owner_id: int) -> tuple[int, int]:
|
||
return (guild_id, owner_id)
|
||
|
||
|
||
def _get_room(guild_id: int, owner_id: int) -> Optional[dict]:
|
||
return _rooms.get(_room_key(guild_id, owner_id))
|
||
|
||
|
||
def _set_room(guild_id: int, owner_id: int, data: dict):
|
||
_rooms[_room_key(guild_id, owner_id)] = data
|
||
mid = data.get("control_message_id")
|
||
if mid:
|
||
_control_message_ids[mid] = (guild_id, owner_id)
|
||
|
||
|
||
def _del_room(guild_id: int, owner_id: int):
|
||
data = _rooms.pop(_room_key(guild_id, owner_id), None)
|
||
if data and data.get("control_message_id"):
|
||
_control_message_ids.pop(data["control_message_id"], None)
|
||
|
||
|
||
def _find_room_by_channel(guild_id: int, channel_id: int) -> Optional[tuple[int, dict]]:
|
||
for (gid, oid), data in _rooms.items():
|
||
if gid == guild_id and data.get("voice_channel_id") == channel_id:
|
||
return (oid, data)
|
||
return None
|
||
|
||
|
||
def _find_room_by_message(message_id: int) -> Optional[tuple[int, int, dict]]:
|
||
key = _control_message_ids.get(message_id)
|
||
if not key:
|
||
return None
|
||
guild_id, owner_id = key
|
||
data = _get_room(guild_id, owner_id)
|
||
if not data:
|
||
_control_message_ids.pop(message_id, None)
|
||
return None
|
||
return (guild_id, owner_id, data)
|
||
|
||
|
||
async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist: set, blacklist: set):
|
||
guild = channel.guild
|
||
everyone = guild.default_role
|
||
overwrites = {}
|
||
everyone_ow = discord.PermissionOverwrite()
|
||
if mode == "open":
|
||
everyone_ow.connect = True
|
||
everyone_ow.view_channel = True
|
||
for uid in blacklist:
|
||
m = guild.get_member(uid)
|
||
if m:
|
||
overwrites[m] = discord.PermissionOverwrite(connect=False, view_channel=True)
|
||
elif mode == "closed":
|
||
everyone_ow.connect = False
|
||
everyone_ow.view_channel = True
|
||
for uid in whitelist:
|
||
m = guild.get_member(uid)
|
||
if m:
|
||
overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True)
|
||
elif mode == "private":
|
||
everyone_ow.connect = False
|
||
everyone_ow.view_channel = False
|
||
for uid in whitelist:
|
||
m = guild.get_member(uid)
|
||
if m:
|
||
overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True)
|
||
overwrites[everyone] = everyone_ow
|
||
await channel.edit(overwrites=overwrites)
|
||
|
||
|
||
async def _handle_reaction_action(bot: discord.Client, guild_id: int, owner_id: int, action: str, channel):
|
||
"""channel = salon vocal (partie texte / onglet Discussion)."""
|
||
room = _get_room(guild_id, owner_id)
|
||
if not room:
|
||
await channel.send("Ce salon n’existe plus.")
|
||
return
|
||
voice_channel = bot.get_channel(room["voice_channel_id"])
|
||
if not voice_channel or not isinstance(voice_channel, discord.VoiceChannel):
|
||
await channel.send("Salon vocal introuvable.")
|
||
return
|
||
|
||
if action in ("open", "closed", "private"):
|
||
room["access_mode"] = action
|
||
await _apply_access_mode(voice_channel, action, room.get("whitelist", set()), room.get("blacklist", set()))
|
||
# Mettre à jour le cadenas dans le nom du channel
|
||
try:
|
||
base_name = voice_channel.name.rstrip(" 🔓🔒")
|
||
new_name = f"{base_name} {_status_emoji(action)}"
|
||
await voice_channel.edit(name=new_name)
|
||
except discord.HTTPException:
|
||
pass
|
||
await channel.send(f"Accès du salon défini sur **{action}**.")
|
||
# Mettre à jour uniquement le statut (cadenas) dans le message de config
|
||
control_message_id = room.get("control_message_id")
|
||
if control_message_id:
|
||
try:
|
||
msg = await channel.fetch_message(control_message_id)
|
||
if msg.embeds:
|
||
embed = msg.embeds[0].copy()
|
||
for i, f in enumerate(embed.fields):
|
||
if f.name == "Statut du salon":
|
||
embed.set_field_at(i, name="Statut du salon", value=_status_display(action), inline=f.inline)
|
||
break
|
||
else:
|
||
embed.add_field(name="Statut du salon", value=_status_display(action), inline=False)
|
||
await msg.edit(embed=embed)
|
||
except discord.HTTPException:
|
||
pass
|
||
|
||
elif action == "whitelist":
|
||
await channel.send("Liste blanche : mentionnez un membre pour l’ajouter/retirer.")
|
||
|
||
elif action == "blacklist":
|
||
await channel.send("Liste noire : mentionnez un membre pour l’ajouter/retirer.")
|
||
|
||
elif action == "purge":
|
||
whitelist = room.get("whitelist", set())
|
||
kicked = 0
|
||
for member in list(voice_channel.members):
|
||
if member.id == owner_id or member.id in whitelist:
|
||
continue
|
||
try:
|
||
await member.move_to(None)
|
||
kicked += 1
|
||
except discord.HTTPException:
|
||
pass
|
||
await channel.send(f"Purge effectuée : {kicked} membre(s) déconnecté(s).")
|
||
|
||
elif action == "transfer":
|
||
await channel.send("Transférer le salon : mentionnez le membre à qui donner la propriété.")
|
||
|
||
elif action in ("speak", "stream"):
|
||
everyone = voice_channel.guild.default_role
|
||
overwrites = dict(voice_channel.overwrites)
|
||
ow = overwrites.get(everyone) or discord.PermissionOverwrite()
|
||
current = getattr(ow, action)
|
||
setattr(ow, action, not current if current is not None else False)
|
||
overwrites[everyone] = ow
|
||
await voice_channel.edit(overwrites=overwrites)
|
||
label = "Micro" if action == "speak" else "Vidéo"
|
||
await channel.send(f"{label} : {'autorisé' if getattr(ow, action) else 'désactivé'} pour tous.")
|
||
|
||
elif action == "status":
|
||
status_text = _status_display(room.get("access_mode", "open"))
|
||
await channel.send(f"Statut du salon : {status_text}\nRépondez avec le nouveau nom du salon pour le modifier.")
|
||
|
||
|
||
async def send_control_panel(bot: discord.Client, guild_id: int, owner: Member, voice_channel: discord.VoiceChannel) -> Optional[int]:
|
||
"""Envoie le message de config avec réactions dans la partie texte du salon vocal (onglet Discussion). Seul le proprio peut réagir. Retourne l’id du message."""
|
||
embed = _build_control_embed(owner, voice_channel, "open")
|
||
|
||
try:
|
||
# Message dans la partie texte du vocal (onglet Discussion à droite)
|
||
msg = await voice_channel.send(embed=embed)
|
||
for emoji, _action, _label in REACTIONS:
|
||
await msg.add_reaction(emoji)
|
||
return msg.id
|
||
except discord.HTTPException as e:
|
||
logging.error(f"Impossible d’envoyer le panneau Auto Room dans le vocal : {e}")
|
||
return None
|
||
|
||
|
||
async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member, before: VoiceState, after: VoiceState):
|
||
config = ConfigurationHelper()
|
||
if not config.getValue("auto_rooms_enable"):
|
||
return
|
||
trigger_channel_id = config.getIntValue("auto_rooms_channel_id")
|
||
if not trigger_channel_id:
|
||
return
|
||
|
||
guild = member.guild
|
||
|
||
if after.channel and after.channel.id == trigger_channel_id:
|
||
category = after.channel.category
|
||
# Nom du salon avec statut (cadenas) à la création
|
||
channel_name = f"Salon de {member.display_name} {_status_emoji('open')}"
|
||
try:
|
||
new_channel = await guild.create_voice_channel(
|
||
name=channel_name,
|
||
category=category,
|
||
reason="Auto room"
|
||
)
|
||
await member.move_to(new_channel)
|
||
control_message_id = await send_control_panel(bot, guild.id, member, new_channel)
|
||
_set_room(guild.id, member.id, {
|
||
"guild_id": guild.id,
|
||
"voice_channel_id": new_channel.id,
|
||
"control_message_id": control_message_id,
|
||
"owner_id": member.id,
|
||
"whitelist": set(),
|
||
"blacklist": set(),
|
||
"access_mode": "open",
|
||
})
|
||
logging.info(f"Auto room créé : {new_channel.name} pour {member.display_name}")
|
||
except discord.HTTPException as e:
|
||
logging.error(f"Erreur création auto room : {e}")
|
||
|
||
if before.channel and before.channel.id != trigger_channel_id:
|
||
result = _find_room_by_channel(guild.id, before.channel.id)
|
||
if result:
|
||
owner_id, room = result
|
||
remaining = [m for m in before.channel.members if m.id != member.id]
|
||
if member.id == owner_id:
|
||
_del_room(guild.id, owner_id)
|
||
try:
|
||
await before.channel.delete(reason="Propriétaire parti (auto room)")
|
||
except discord.HTTPException:
|
||
pass
|
||
elif len(remaining) == 0:
|
||
_del_room(guild.id, owner_id)
|
||
try:
|
||
await before.channel.delete(reason="Auto room vide")
|
||
except discord.HTTPException:
|
||
pass
|
||
|
||
|
||
async def on_raw_reaction_add_auto_rooms(bot: discord.Client, payload: discord.RawReactionActionEvent):
|
||
"""Seul le propriétaire peut réagir ; on retire la réaction des autres."""
|
||
if payload.user_id == bot.user.id:
|
||
return
|
||
if not ConfigurationHelper().getValue("auto_rooms_enable"):
|
||
return
|
||
room_info = _find_room_by_message(payload.message_id)
|
||
if not room_info:
|
||
return
|
||
guild_id, owner_id, room = room_info
|
||
if payload.user_id != owner_id:
|
||
try:
|
||
channel = bot.get_channel(payload.channel_id)
|
||
if channel:
|
||
msg = await channel.fetch_message(payload.message_id)
|
||
user = payload.member or await bot.fetch_user(payload.user_id)
|
||
await msg.remove_reaction(payload.emoji, user)
|
||
except discord.HTTPException:
|
||
pass
|
||
return
|
||
|
||
emoji_str = str(payload.emoji)
|
||
action = None
|
||
for e, a, _ in REACTIONS:
|
||
if e == emoji_str:
|
||
action = a
|
||
break
|
||
if not action:
|
||
return
|
||
|
||
# Canal = salon vocal (le message est dans la partie texte du vocal)
|
||
channel = bot.get_channel(payload.channel_id)
|
||
if not channel or not hasattr(channel, "send"):
|
||
return
|
||
|
||
await _handle_reaction_action(bot, guild_id, owner_id, action, channel)
|
||
|
||
try:
|
||
msg = await channel.fetch_message(payload.message_id)
|
||
user = payload.member or await bot.fetch_user(payload.user_id)
|
||
await msg.remove_reaction(payload.emoji, user)
|
||
except discord.HTTPException:
|
||
pass
|