2026-01-01 18:48:25 +01:00
|
|
|
|
# commandes/backup.py
|
|
|
|
|
|
import os
|
|
|
|
|
|
import io
|
|
|
|
|
|
import json
|
|
|
|
|
|
import zipfile
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from typing import Dict, Any, List
|
|
|
|
|
|
|
2026-05-06 17:07:09 +02:00
|
|
|
|
import disnake
|
|
|
|
|
|
from disnake.ext import commands
|
2026-01-01 18:48:25 +01:00
|
|
|
|
|
|
|
|
|
|
BACKUPS_ROOT = "data/security_backups" # dossier de sortie des sauvegardes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ensure_dir(path: str) -> None:
|
|
|
|
|
|
os.makedirs(path, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def human_dt() -> str:
|
|
|
|
|
|
return datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def progress_bar(pct: float, width: int = 20) -> str:
|
|
|
|
|
|
pct = max(0.0, min(1.0, pct))
|
|
|
|
|
|
filled = int(round(width * pct))
|
|
|
|
|
|
return f"[{'█'*filled}{'─'*(width-filled)}] {int(pct*100)}%"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def estimate_duration_seconds(
|
|
|
|
|
|
nb_roles: int,
|
|
|
|
|
|
nb_categories: int,
|
|
|
|
|
|
nb_text_channels: int,
|
|
|
|
|
|
nb_members: int,
|
|
|
|
|
|
save_messages: bool,
|
|
|
|
|
|
message_limit_per_channel: int,
|
|
|
|
|
|
) -> int:
|
|
|
|
|
|
meta_cost = nb_roles * 0.02 + nb_categories * 0.03 + nb_text_channels * 0.05 + nb_members * 0.01
|
|
|
|
|
|
if save_messages:
|
|
|
|
|
|
pages_per_chan = max(1, (message_limit_per_channel + 99) // 100)
|
|
|
|
|
|
msg_cost = nb_text_channels * pages_per_chan * 0.12
|
|
|
|
|
|
else:
|
|
|
|
|
|
msg_cost = 0.0
|
|
|
|
|
|
base_overhead = 3.0
|
|
|
|
|
|
return int(meta_cost + msg_cost + base_overhead)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-06 17:07:09 +02:00
|
|
|
|
def serialize_overwrites(channel: disnake.abc.GuildChannel) -> List[Dict[str, Any]]:
|
2026-01-01 18:48:25 +01:00
|
|
|
|
serialized: List[Dict[str, Any]] = []
|
|
|
|
|
|
for target, perms in channel.overwrites.items():
|
2026-05-06 17:07:09 +02:00
|
|
|
|
target_type = "role" if isinstance(target, disnake.Role) else "member"
|
2026-01-01 18:48:25 +01:00
|
|
|
|
serialized.append({
|
|
|
|
|
|
"target_type": target_type,
|
|
|
|
|
|
"target_id": target.id,
|
|
|
|
|
|
"permissions": perms._values
|
|
|
|
|
|
})
|
|
|
|
|
|
return serialized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Backup(commands.Cog):
|
|
|
|
|
|
"""Sauvegarde de serveur avec estimation & progression."""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, bot: commands.Bot):
|
|
|
|
|
|
self.bot = bot
|
|
|
|
|
|
|
2026-05-06 17:07:09 +02:00
|
|
|
|
@commands.slash_command(name="backup", description="Outils de sauvegarde du serveur")
|
|
|
|
|
|
async def backup_group(self, interaction: disnake.ApplicationCommandInteraction):
|
|
|
|
|
|
pass
|
2026-01-01 18:48:25 +01:00
|
|
|
|
|
2026-05-06 17:07:09 +02:00
|
|
|
|
@backup_group.sub_command(name="create", description="Créer une sauvegarde du serveur")
|
2026-01-01 18:48:25 +01:00
|
|
|
|
async def backup_create(
|
|
|
|
|
|
self,
|
2026-05-06 17:07:09 +02:00
|
|
|
|
interaction: disnake.ApplicationCommandInteraction,
|
|
|
|
|
|
save_messages: bool = commands.Param(True, description="Inclure l'export des messages"),
|
|
|
|
|
|
privacy_notice: bool = commands.Param(True, description="Afficher un avertissement public si messages sauvegardés"),
|
|
|
|
|
|
message_limit_per_channel: int = commands.Param(10000, description="Nombre max de messages à exporter par salon (0 = tous)")
|
2026-01-01 18:48:25 +01:00
|
|
|
|
):
|
|
|
|
|
|
guild = interaction.guild
|
|
|
|
|
|
assert guild is not None, "Cette commande doit être utilisée dans un serveur."
|
|
|
|
|
|
|
|
|
|
|
|
# Vérification de la whitelist
|
|
|
|
|
|
whitelist_cog = self.bot.get_cog("whitelist")
|
|
|
|
|
|
if not whitelist_cog:
|
|
|
|
|
|
await interaction.response.send_message("❌ Cog Whitelist introuvable.", ephemeral=True)
|
|
|
|
|
|
return
|
|
|
|
|
|
if not whitelist_cog.is_whitelisted(guild.id, interaction.user.id):
|
|
|
|
|
|
await interaction.response.send_message(
|
|
|
|
|
|
"❌ Vous n'êtes pas dans la Whitelist.", ephemeral=True
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-05-06 17:07:09 +02:00
|
|
|
|
# Deferre l'interaction
|
|
|
|
|
|
await interaction.response.defer(ephemeral=True)
|
2026-01-01 18:48:25 +01:00
|
|
|
|
|
|
|
|
|
|
# Avertissement de confidentialité
|
|
|
|
|
|
if privacy_notice and save_messages:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await interaction.channel.send(
|
2026-05-06 17:07:09 +02:00
|
|
|
|
embed=disnake.Embed(
|
2026-01-01 18:48:25 +01:00
|
|
|
|
title="📢 Information de confidentialité (transparence)",
|
|
|
|
|
|
description=(
|
|
|
|
|
|
"Une **sauvegarde du serveur** est en cours et **inclus les messages**.\n"
|
|
|
|
|
|
"Vous êtes dans l'obligation de prévenir les membres."
|
|
|
|
|
|
),
|
2026-05-06 17:07:09 +02:00
|
|
|
|
color=disnake.Color.orange()
|
2026-01-01 18:48:25 +01:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# Préparation chemins
|
|
|
|
|
|
ts = human_dt()
|
|
|
|
|
|
base_dir = os.path.join(BACKUPS_ROOT, f"{guild.id}_{ts}")
|
|
|
|
|
|
ensure_dir(base_dir)
|
|
|
|
|
|
ensure_dir(os.path.join(base_dir, "messages"))
|
|
|
|
|
|
|
|
|
|
|
|
# Estimation
|
|
|
|
|
|
nb_roles = len(guild.roles)
|
|
|
|
|
|
nb_categories = len(guild.categories)
|
|
|
|
|
|
nb_text_channels = len(guild.text_channels)
|
|
|
|
|
|
nb_members = guild.member_count or len(guild.members)
|
|
|
|
|
|
|
|
|
|
|
|
if message_limit_per_channel < 0:
|
|
|
|
|
|
message_limit_per_channel = 0
|
|
|
|
|
|
|
|
|
|
|
|
estimation_s = estimate_duration_seconds(
|
|
|
|
|
|
nb_roles, nb_categories, nb_text_channels, nb_members, save_messages,
|
|
|
|
|
|
message_limit_per_channel if message_limit_per_channel else 100000
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-06 17:07:09 +02:00
|
|
|
|
prog_embed = disnake.Embed(
|
2026-01-01 18:48:25 +01:00
|
|
|
|
title="🗄️ Sauvegarde en cours",
|
|
|
|
|
|
description=(
|
|
|
|
|
|
f"**Serveur :** {guild.name} (`{guild.id}`)\n"
|
|
|
|
|
|
f"**Estimation :** ~{estimation_s} secondes\n"
|
|
|
|
|
|
f"**Options** : messages={'oui' if save_messages else 'non'} • limite/chan={message_limit_per_channel or 'tous'}"
|
|
|
|
|
|
),
|
2026-05-06 17:07:09 +02:00
|
|
|
|
color=disnake.Color.blurple()
|
2026-01-01 18:48:25 +01:00
|
|
|
|
)
|
|
|
|
|
|
prog_embed.add_field(name="Progression", value=progress_bar(0.0), inline=False)
|
|
|
|
|
|
progress_msg = await interaction.followup.send(embed=prog_embed, ephemeral=False)
|
|
|
|
|
|
|
|
|
|
|
|
# ================= EXPORT =================
|
|
|
|
|
|
weights = {"roles":0.08,"categories":0.12,"channels":0.20,"members":0.10,"messages":0.10 if save_messages else 0.0,"zip":0.10}
|
|
|
|
|
|
done = 0.0
|
|
|
|
|
|
async def bump(step_key:str, extra_pct:float=0.0,note:str=""):
|
|
|
|
|
|
nonlocal done
|
|
|
|
|
|
done += weights.get(step_key,0.0)+extra_pct
|
|
|
|
|
|
done = min(done,0.999)
|
|
|
|
|
|
prog_embed.set_field_at(0,name="Progression",value=progress_bar(done),inline=False)
|
|
|
|
|
|
if note:
|
|
|
|
|
|
prog_embed.set_footer(text=note)
|
|
|
|
|
|
await progress_msg.edit(embed=prog_embed)
|
|
|
|
|
|
|
|
|
|
|
|
# --- RÔLES ---
|
|
|
|
|
|
roles_dump = [{"id": r.id,"name": r.name,"color": r.color.value,"hoist": r.hoist,"mentionable": r.mentionable,"permissions": r.permissions.value,"position": r.position} for r in guild.roles]
|
|
|
|
|
|
with open(os.path.join(base_dir,"roles.json"),"w",encoding="utf-8") as f:
|
|
|
|
|
|
json.dump(roles_dump,f,indent=2,ensure_ascii=False)
|
|
|
|
|
|
await bump("roles", note="Rôles exportés")
|
|
|
|
|
|
|
|
|
|
|
|
# --- CATÉGORIES ---
|
|
|
|
|
|
cats_dump = [{"id": c.id,"name": c.name,"position": c.position,"nsfw": getattr(c,"nsfw",False),"overwrites": serialize_overwrites(c)} for c in guild.categories]
|
|
|
|
|
|
with open(os.path.join(base_dir,"categories.json"),"w",encoding="utf-8") as f:
|
|
|
|
|
|
json.dump(cats_dump,f,indent=2,ensure_ascii=False)
|
|
|
|
|
|
await bump("categories", note="Catégories exportées")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- SALONS ---
|
|
|
|
|
|
chans_dump=[]
|
|
|
|
|
|
for ch in guild.channels:
|
|
|
|
|
|
base={"id":ch.id,"name":ch.name,"type":str(ch.type),"position":ch.position,"category_id":ch.category.id if getattr(ch,"category",None) else None,"overwrites":serialize_overwrites(ch)}
|
2026-05-06 17:07:09 +02:00
|
|
|
|
if isinstance(ch,disnake.TextChannel):
|
2026-01-01 18:48:25 +01:00
|
|
|
|
base.update({"topic":ch.topic,"nsfw":ch.nsfw,"slowmode_delay":ch.slowmode_delay,"default_auto_archive_duration":ch.default_auto_archive_duration})
|
2026-05-06 17:07:09 +02:00
|
|
|
|
elif isinstance(ch,disnake.VoiceChannel):
|
2026-01-01 18:48:25 +01:00
|
|
|
|
base.update({"bitrate":ch.bitrate,"user_limit":ch.user_limit,"rtc_region":str(ch.rtc_region) if ch.rtc_region else None})
|
2026-05-06 17:07:09 +02:00
|
|
|
|
elif isinstance(ch,disnake.ForumChannel):
|
2026-01-01 18:48:25 +01:00
|
|
|
|
base.update({"nsfw":ch.nsfw,"default_auto_archive_duration":ch.default_auto_archive_duration})
|
|
|
|
|
|
chans_dump.append(base)
|
|
|
|
|
|
with open(os.path.join(base_dir,"channels.json"),"w",encoding="utf-8") as f:
|
|
|
|
|
|
json.dump(chans_dump,f,indent=2,ensure_ascii=False)
|
|
|
|
|
|
await bump("channels", note="Salons exportés")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- MEMBRES ---
|
|
|
|
|
|
members_dump=[]
|
|
|
|
|
|
for m in guild.members:
|
|
|
|
|
|
members_dump.append({"id":m.id,"name":str(m),"nick":m.nick,"roles":[r.id for r in m.roles if r!=guild.default_role],"bot":m.bot,"joined_at":m.joined_at.isoformat() if m.joined_at else None})
|
|
|
|
|
|
with open(os.path.join(base_dir,"members.json"),"w",encoding="utf-8") as f:
|
|
|
|
|
|
json.dump(members_dump,f,indent=2,ensure_ascii=False)
|
|
|
|
|
|
await bump("members", note="Membres exportés")
|
|
|
|
|
|
|
|
|
|
|
|
# --- MESSAGES ---
|
|
|
|
|
|
if save_messages:
|
|
|
|
|
|
text_chans=list(guild.text_channels)
|
|
|
|
|
|
total_tc=len(text_chans)
|
|
|
|
|
|
per_chan_weight=weights["messages"]/max(1,total_tc)
|
|
|
|
|
|
exported_total=0
|
|
|
|
|
|
for idx,tc in enumerate(text_chans,start=1):
|
|
|
|
|
|
file_path=os.path.join(base_dir,"messages",f"{tc.id}.jsonl")
|
|
|
|
|
|
count=0
|
|
|
|
|
|
with open(file_path,"w",encoding="utf-8") as f:
|
|
|
|
|
|
try:
|
|
|
|
|
|
async for msg in tc.history(limit=message_limit_per_channel if message_limit_per_channel>0 else None, oldest_first=True):
|
|
|
|
|
|
data={"id":msg.id,"channel_id":msg.channel.id,"author_id":msg.author.id,"author_bot":msg.author.bot,"created_at":msg.created_at.isoformat(),"content":msg.content,"attachments":[a.url for a in msg.attachments],"embeds":[e.to_dict() for e in msg.embeds],"reference":{"message_id":getattr(msg.reference,"message_id",None),"channel_id":getattr(msg.reference,"channel_id",None)} if msg.reference else None}
|
|
|
|
|
|
f.write(json.dumps(data,ensure_ascii=False)+"\n")
|
|
|
|
|
|
count+=1
|
2026-05-06 17:07:09 +02:00
|
|
|
|
except disnake.Forbidden: pass
|
2026-01-01 18:48:25 +01:00
|
|
|
|
except Exception: pass
|
|
|
|
|
|
exported_total+=count
|
|
|
|
|
|
done+=per_chan_weight
|
|
|
|
|
|
prog_embed.set_field_at(0,name="Progression",value=progress_bar(min(done,0.999)),inline=False)
|
|
|
|
|
|
prog_embed.set_footer(text=f"Messages exportés : {exported_total} | Salon {idx}/{total_tc}")
|
|
|
|
|
|
await progress_msg.edit(embed=prog_embed)
|
|
|
|
|
|
|
|
|
|
|
|
# --- ZIP FINAL ---
|
|
|
|
|
|
zip_path=os.path.join(BACKUPS_ROOT,f"{guild.id}_{ts}.zip")
|
|
|
|
|
|
try:
|
|
|
|
|
|
with zipfile.ZipFile(zip_path,"w",compression=zipfile.ZIP_DEFLATED,compresslevel=6) as zf:
|
|
|
|
|
|
for root,_,files in os.walk(base_dir):
|
|
|
|
|
|
for name in files:
|
|
|
|
|
|
full=os.path.join(root,name)
|
|
|
|
|
|
rel=os.path.relpath(full,base_dir)
|
|
|
|
|
|
zf.write(full,rel)
|
|
|
|
|
|
except Exception as exc:
|
2026-05-06 17:07:09 +02:00
|
|
|
|
prog_embed.color=disnake.Color.red()
|
2026-01-01 18:48:25 +01:00
|
|
|
|
prog_embed.set_field_at(0,name="Progression",value=progress_bar(1.0),inline=False)
|
|
|
|
|
|
prog_embed.set_footer(text=f"Erreur lors de la création du ZIP : {exc}")
|
|
|
|
|
|
await progress_msg.edit(embed=prog_embed)
|
|
|
|
|
|
await interaction.followup.send("❌ Erreur lors de la compression ZIP. Si l'erreur persiste veuillez ouvrir un ticket sur Omega Kube", ephemeral=True)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# Finalisation
|
2026-05-06 17:07:09 +02:00
|
|
|
|
prog_embed.color=disnake.Color.green()
|
2026-01-01 18:48:25 +01:00
|
|
|
|
prog_embed.set_field_at(0,name="Progression",value=progress_bar(1.0),inline=False)
|
|
|
|
|
|
prog_embed.add_field(name="Archive",value=f"`{zip_path}`",inline=False)
|
|
|
|
|
|
prog_embed.set_footer(text="Sauvegarde terminée ✅")
|
|
|
|
|
|
await progress_msg.edit(embed=prog_embed)
|
|
|
|
|
|
await interaction.followup.send("🎉 Sauvegarde terminée. Un message de progression avec le chemin de l’archive a été publié.", ephemeral=True)
|
|
|
|
|
|
|
2026-05-06 17:07:09 +02:00
|
|
|
|
@backup_group.sub_command(name="list", description="Lister les archives de sauvegarde disponibles")
|
|
|
|
|
|
async def backup_list(self, interaction: disnake.ApplicationCommandInteraction):
|
2026-01-01 18:48:25 +01:00
|
|
|
|
guild = interaction.guild
|
|
|
|
|
|
assert guild is not None, "Cette commande doit être utilisée dans un serveur."
|
|
|
|
|
|
|
|
|
|
|
|
whitelist_cog = self.bot.get_cog("whitelist")
|
|
|
|
|
|
if not whitelist_cog:
|
|
|
|
|
|
await interaction.response.send_message("❌ Cog Whitelist introuvable.", ephemeral=True)
|
|
|
|
|
|
return
|
|
|
|
|
|
if not whitelist_cog.is_whitelisted(guild.id, interaction.user.id):
|
|
|
|
|
|
await interaction.response.send_message(
|
|
|
|
|
|
"❌ Vous n'êtes pas autorisé à utiliser cette commande.", ephemeral=True
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-05-06 17:07:09 +02:00
|
|
|
|
await interaction.response.defer(ephemeral=True)
|
2026-01-01 18:48:25 +01:00
|
|
|
|
ensure_dir(BACKUPS_ROOT)
|
|
|
|
|
|
files = [f for f in os.listdir(BACKUPS_ROOT) if f.endswith(".zip")]
|
|
|
|
|
|
files.sort(reverse=True)
|
|
|
|
|
|
|
|
|
|
|
|
if not files:
|
|
|
|
|
|
await interaction.followup.send("Aucune archive trouvée dans `backups/`.", ephemeral=True)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
desc="\n".join(f"• `{name}`" for name in files[:20])
|
2026-05-06 17:07:09 +02:00
|
|
|
|
embed=disnake.Embed(title="📦 Archives disponibles",description=desc,color=disnake.Color.blurple())
|
2026-01-01 18:48:25 +01:00
|
|
|
|
if len(files)>20:
|
|
|
|
|
|
embed.set_footer(text=f"... et {len(files)-20} de plus")
|
|
|
|
|
|
await interaction.followup.send(embed=embed,ephemeral=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-09 18:42:42 +02:00
|
|
|
|
def setup(bot):
|
|
|
|
|
|
bot.add_cog(Backup(bot))
|