# 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] = {} # 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"), ("📊", "soundboards", "Soundboards"), ("📝", "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.""" if access_mode == "private": return "🔐" return "🔓" if access_mode == "open" else "🔒" 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", description=( "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.orange() ) # 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 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) _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) 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, room: dict): guild = channel.guild everyone = guild.default_role 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: overwrites[m] = discord.PermissionOverwrite(connect=False, view_channel=True) 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: overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True) 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.") 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()), room) _persist_room(guild_id, owner_id, room) # Mettre à jour le cadenas dans le nom du channel try: base_name = voice_channel.name.rstrip(" 🔓🔒🔐") new_name = f"{base_name} {_status_emoji(action)}" await voice_channel.edit(name=new_name) except discord.HTTPException: pass await channel.send(f"Accès du salon défini sur **{_status_display(action)}**.") # Mettre à jour l'embed await _update_control_panel(bot, guild_id, owner_id, channel) elif action == "whitelist": 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": 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()) 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( 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", "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) 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) 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": 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 _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) 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 _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 if not trigger_channel_id: return guild = member.guild if after.channel and after.channel.id == trigger_channel_id: existing_room = _get_room(guild.id, member.id) if existing_room: old_channel = bot.get_channel(existing_room["voice_channel_id"]) if old_channel and isinstance(old_channel, discord.VoiceChannel): # Ne jamais abandonner une room encore occupée : le propriétaire y retourne. await member.move_to(old_channel) return _del_room(guild.id, member.id) category = after.channel.category channel_name = f"Salon de {member.display_name} {_status_emoji('open')}" try: new_channel = await guild.create_voice_channel( name=channel_name, category=category, reason="Auto room" ) await member.move_to(new_channel) # Créer la room data d'abord room_data = { "guild_id": guild.id, "voice_channel_id": new_channel.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 != 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 and remaining: new_owner = remaining[0] _del_room(guild.id, owner_id) room["owner_id"] = new_owner.id _set_room(guild.id, new_owner.id, room) await before.channel.send(f"👑 {new_owner.mention} est maintenant propriétaire du salon.") await _update_control_panel(bot, guild.id, new_owner.id, before.channel) 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_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 enabled, _ = _auto_rooms_config() if not enabled: 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