Harden auto room lifecycle and persistence
This commit is contained in:
+13
-1
@@ -210,6 +210,19 @@ class YouTubeVideoHistory(db.Model):
|
||||
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)
|
||||
@@ -259,4 +272,3 @@ class ModShoutboxMessage(db.Model):
|
||||
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)
|
||||
|
||||
|
||||
@@ -193,6 +193,18 @@ CREATE TABLE IF NOT EXISTS `youtube_video_history` (
|
||||
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
|
||||
);
|
||||
|
||||
@@ -36,7 +36,7 @@ from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInvit
|
||||
from discordbot.rules_ack import assign_rules_arrival_on_join, register_persistent_rules_view
|
||||
from discordbot.patreon import checkPatreonPosts
|
||||
from discordbot.youtube import checkYouTubeVideos
|
||||
from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms, on_message_auto_rooms, cleanup_orphaned_auto_rooms
|
||||
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):
|
||||
@@ -97,6 +97,7 @@ class DiscordBot(discord.Client):
|
||||
for guild in self.guilds:
|
||||
await updateInviteCache(guild)
|
||||
|
||||
await restore_auto_rooms(self)
|
||||
await cleanup_orphaned_auto_rooms(self)
|
||||
|
||||
# on_ready est rappelé après une reconnexion : ne pas démarrer plusieurs
|
||||
|
||||
+119
-29
@@ -1,11 +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] = {}
|
||||
@@ -131,12 +135,41 @@ 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]]:
|
||||
@@ -158,7 +191,7 @@ def _find_room_by_message(message_id: int) -> Optional[tuple[int, int, dict]]:
|
||||
return (guild_id, owner_id, data)
|
||||
|
||||
|
||||
async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist: set, blacklist: set):
|
||||
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
|
||||
@@ -175,10 +208,11 @@ async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist
|
||||
if mode == "open":
|
||||
everyone_ow.connect = True
|
||||
everyone_ow.view_channel = True
|
||||
# Retirer les overwrites des membres qui ne sont plus dans la blacklist
|
||||
# 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 not in blacklist:
|
||||
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:
|
||||
@@ -188,10 +222,9 @@ async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist
|
||||
elif mode == "closed":
|
||||
everyone_ow.connect = False
|
||||
everyone_ow.view_channel = True
|
||||
# Retirer les overwrites des membres qui ne sont plus dans la whitelist
|
||||
for target in list(overwrites.keys()):
|
||||
if target != everyone and isinstance(target, discord.Member):
|
||||
if target.id not in whitelist:
|
||||
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:
|
||||
@@ -201,10 +234,9 @@ async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist
|
||||
elif mode == "private":
|
||||
everyone_ow.connect = False
|
||||
everyone_ow.view_channel = False
|
||||
# Retirer les overwrites des membres qui ne sont plus dans la whitelist
|
||||
for target in list(overwrites.keys()):
|
||||
if target != everyone and isinstance(target, discord.Member):
|
||||
if target.id not in whitelist:
|
||||
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:
|
||||
@@ -214,6 +246,7 @@ async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist
|
||||
|
||||
overwrites[everyone] = everyone_ow
|
||||
await channel.edit(overwrites=overwrites)
|
||||
room["managed_member_ids"] = set(blacklist if mode == "open" else whitelist)
|
||||
|
||||
|
||||
async def _handle_reaction_action(bot: discord.Client, guild_id: int, owner_id: int, action: str, channel):
|
||||
@@ -229,7 +262,8 @@ async def _handle_reaction_action(bot: discord.Client, guild_id: int, owner_id:
|
||||
|
||||
if action in ("open", "closed", "private"):
|
||||
room["access_mode"] = action
|
||||
await _apply_access_mode(voice_channel, action, room.get("whitelist", set()), room.get("blacklist", set()))
|
||||
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(" 🔓🔒🔐")
|
||||
@@ -361,14 +395,24 @@ _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."""
|
||||
config = ConfigurationHelper()
|
||||
if not config.getValue("auto_rooms_enable"):
|
||||
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
|
||||
|
||||
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:
|
||||
@@ -382,6 +426,9 @@ async def cleanup_orphaned_auto_rooms(bot: discord.Client):
|
||||
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
|
||||
@@ -389,11 +436,40 @@ async def cleanup_orphaned_auto_rooms(bot: discord.Client):
|
||||
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):
|
||||
config = ConfigurationHelper()
|
||||
if not config.getValue("auto_rooms_enable"):
|
||||
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
|
||||
|
||||
@@ -403,13 +479,11 @@ async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member,
|
||||
existing_room = _get_room(guild.id, member.id)
|
||||
if existing_room:
|
||||
old_channel = bot.get_channel(existing_room["voice_channel_id"])
|
||||
_del_room(guild.id, member.id)
|
||||
if old_channel and isinstance(old_channel, discord.VoiceChannel):
|
||||
if len(old_channel.members) == 0:
|
||||
try:
|
||||
await old_channel.delete(reason="Auto room remplacée")
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
# 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')}"
|
||||
@@ -429,10 +503,15 @@ async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member,
|
||||
"owner_id": member.id,
|
||||
"whitelist": set(),
|
||||
"blacklist": set(),
|
||||
"managed_member_ids": set(),
|
||||
"access_mode": "open",
|
||||
}
|
||||
|
||||
control_message_id = await send_control_panel(bot, guild.id, member, new_channel, room_data)
|
||||
if not control_message_id:
|
||||
await new_channel.delete(reason="Panneau Auto Room impossible à créer")
|
||||
await member.move_to(after.channel)
|
||||
return
|
||||
room_data["control_message_id"] = control_message_id
|
||||
_set_room(guild.id, member.id, room_data)
|
||||
|
||||
@@ -445,12 +524,13 @@ async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member,
|
||||
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:
|
||||
@@ -463,7 +543,8 @@ 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
|
||||
if not ConfigurationHelper().getValue("auto_rooms_enable"):
|
||||
enabled, _ = _auto_rooms_config()
|
||||
if not enabled:
|
||||
return
|
||||
|
||||
# Vérifier si c'est dans un salon vocal (partie texte)
|
||||
@@ -515,7 +596,11 @@ async def on_message_auto_rooms(bot: discord.Client, message: discord.Message):
|
||||
whitelist.add(target.id)
|
||||
await message.channel.send(f"✅ {target.mention} a été ajouté à la liste blanche.")
|
||||
room["whitelist"] = whitelist
|
||||
await _apply_access_mode(voice_channel, room.get("access_mode", "open"), whitelist, room.get("blacklist", set()))
|
||||
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
|
||||
|
||||
@@ -532,7 +617,11 @@ async def on_message_auto_rooms(bot: discord.Client, message: discord.Message):
|
||||
blacklist.add(target.id)
|
||||
await message.channel.send(f"✅ {target.mention} a été ajouté à la liste noire.")
|
||||
room["blacklist"] = blacklist
|
||||
await _apply_access_mode(voice_channel, room.get("access_mode", "open"), room.get("whitelist", set()), 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
|
||||
|
||||
@@ -568,7 +657,8 @@ async def on_raw_reaction_add_auto_rooms(bot: discord.Client, payload: discord.R
|
||||
"""Seul le propriétaire peut réagir ; on retire la réaction des autres."""
|
||||
if payload.user_id == bot.user.id:
|
||||
return
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user