migration vers new components V2, et ajout de diverse fonctions !

This commit is contained in:
Mathis 2026-05-16 18:05:04 +02:00
parent 3590cb748f
commit ba9e297cb8
14 changed files with 851 additions and 371 deletions

1
.gitignore vendored
View file

@ -36,3 +36,4 @@ logs/
.vscode/
.idea/
.DS_Store
.roo/

9
TODO.md Normal file
View file

@ -0,0 +1,9 @@
# TODO - Panel sanctions (components V2)
- [ ] Revoir `commandes/moderation.py` : ajouter `panel_channel_id` dans la table `mod_config`
- [ ] Mettre à jour `/modconfig` avec un nouveau bouton/sélecteur “Salon sanctions” (ChannelSelect) (style components V2, callbacks dédiés)
- [ ] Ajouter un formatter “panneau sanctions” (liste bullets + citations/raison + date + modérateur + durée si TIMEOUT)
- [ ] Ajouter une fonction `send_sanction_panel(...)` et lappeler dans `warn`, `timeout`, `kick`, `ban` (push automatique)
- [ ] Enrichir `/modpanel` pour afficher les sanctions actives récentes “proprement” (et garder lUI actuelle)
- [ ] Nettoyer les callbacks (supprimer les `lambda` dans `ModConfigView` si présents)
- [ ] Tester localement : `/modconfig` -> config channel, puis exécuter des sanctions et vérifier laffichage dans le salon

118
bot.py
View file

@ -15,6 +15,59 @@ import logging
from src.advanced_logger import AdvancedLogger
import subprocess
SENSITIVE_PERMISSIONS = {
"administrator", "manage_guild", "manage_roles", "manage_channels",
"kick_members", "ban_members", "manage_messages", "manage_webhooks",
"moderate_members"
}
class RoleApprovalView(disnake.ui.View):
def __init__(self, member: disnake.Member, roles: list, owner: disnake.Member):
super().__init__(timeout=86400) # 24 hours
self.member = member
self.roles = roles
self.owner = owner
@disnake.ui.button(label="Accepter", style=disnake.ButtonStyle.green, emoji="")
async def accept(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction):
# Check if member still in guild
guild = self.member.guild
current_member = guild.get_member(self.member.id)
if not current_member:
await interaction.response.send_message("❌ L'utilisateur n'est plus sur le serveur.", ephemeral=True)
self.stop()
return
try:
await current_member.add_roles(*self.roles, reason="Restauration sécurisée approuvée par le propriétaire")
await interaction.response.send_message(f"✅ Rôles sensibles restaurés pour {self.member.mention}.", ephemeral=True)
# Update original message
components = [
disnake.ui.Container(
disnake.ui.TextDisplay(f"✅ Vous avez approuvé la restauration des rôles pour {self.member.mention}."),
disnake.ui.TextDisplay(f"🛡️ Rôles : {', '.join([r.name for r in self.roles])}")
)
]
await interaction.edit_original_response(embed=None, components=components, view=None)
self.stop()
except Exception as e:
await interaction.response.send_message(f"❌ Erreur lors de l'attribution : {e}", ephemeral=True)
@disnake.ui.button(label="Refuser", style=disnake.ButtonStyle.red, emoji="✖️")
async def deny(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction):
await interaction.response.send_message(f"❌ Vous avez refusé la restauration pour {self.member.mention}.", ephemeral=True)
# Update original message
components = [
disnake.ui.Container(
disnake.ui.TextDisplay(f"❌ Vous avez refusé la restauration des rôles pour {self.member.mention}."),
disnake.ui.TextDisplay(f"🛡️ Rôles : {', '.join([r.name for r in self.roles])}")
)
]
await interaction.edit_original_response(embed=None, components=components, view=None)
self.stop()
# Récupération et validation de l'ID d'application
application_id_raw = os.getenv("APPLICATION_ID")
kuby_logger.debug(f"Raw APPLICATION_ID: {application_id_raw}")
@ -39,6 +92,7 @@ class MyBot(commands.Bot):
command_prefix="!",
intents=intents,
application_id=application_id,
command_sync_flags=commands.CommandSyncFlags.all()
)
kuby_logger.info("MyBot instance created successfully")
@ -89,8 +143,8 @@ class MyBot(commands.Bot):
"commandes.bug_report",
"commandes.convocation",
"commandes.absence_staff",
"commandes.website_sync",
"commandes.moderation",
"commandes.invites",
]
for extension in extensions:
@ -106,7 +160,7 @@ class MyBot(commands.Bot):
if await self.is_owner(inter.author):
return True
command_name = inter.data.name
if command_name in ["ticketconfig", "ticketpanel", "ticket_config", "ticket_panel", "ticket_whitelist_config"]:
if command_name in ["ticketconfig", "ticketpanel", "ticket_config", "ticket_panel", "ticket_whitelist_config", "invitations"]:
return True
whitelist_cog = self.get_cog("WhitelistMonitor")
if not whitelist_cog:
@ -123,7 +177,7 @@ class MyBot(commands.Bot):
kuby_logger.error(f"⚠️ [Check] Erreur critique: {e}", exc_info=True)
return True
self.add_app_command_check(global_slash_whitelist_check)
# self.add_app_command_check(global_slash_whitelist_check)
# --- LOGGING DES INTERACTIONS ---
@self.listen("on_interaction")
@ -170,11 +224,63 @@ class MyBot(commands.Bot):
roles_to_add = [guild.get_role(rid) for rid in previous_roles.get("role_ids", [])]
roles_to_add = [r for r in roles_to_add if r and r < guild.me.top_role]
if roles_to_add:
# Separate roles into safe and sensitive
safe_roles = []
sensitive_roles = []
for role in roles_to_add:
is_sensitive = False
for perm in SENSITIVE_PERMISSIONS:
if getattr(role.permissions, perm, False):
is_sensitive = True
break
if is_sensitive:
sensitive_roles.append(role)
else:
safe_roles.append(role)
# Restore safe roles automatically
if safe_roles:
retries = 3
for i in range(retries):
try:
await member.add_roles(*roles_to_add)
kuby_logger.info(f"Restored {len(roles_to_add)} roles for {member}")
await member.add_roles(*safe_roles)
kuby_logger.info(f"Restored {len(safe_roles)} safe roles for {member}")
break
except (disnake.HTTPException, disnake.GatewayNotFound, asyncio.TimeoutError) as e:
is_transient = isinstance(e, disnake.HTTPException) and e.status in [500, 502, 503, 504]
if not isinstance(e, disnake.HTTPException): is_transient = True
if is_transient and i < retries - 1:
await asyncio.sleep((i + 1) * 2)
continue
kuby_logger.error(f"Failed to restore safe roles for {member}: {e}")
break
except Exception as e:
kuby_logger.error(f"Failed to restore roles: {e}")
kuby_logger.error(f"Error restoring safe roles for {member}: {e}")
break
# Handle sensitive roles with owner approval
if sensitive_roles:
try:
owner = guild.owner or await guild.fetch_member(guild.owner_id)
embed = disnake.Embed(
title="⚠️ Restauration de rôles sensibles",
description=(
f"L'utilisateur **{member}** ({member.id}) vient de rejoindre **{guild.name}**.\n\n"
"Certains de ses anciens rôles possèdent des permissions sensibles :\n"
f"🛡️ **Rôles en attente :** {', '.join([r.mention for r in sensitive_roles])}\n\n"
"Voulez-vous restaurer ces rôles ?"
),
color=disnake.Color.yellow()
)
embed.set_thumbnail(url=member.display_avatar.url)
view = RoleApprovalView(member, sensitive_roles, owner)
await owner.send(embed=embed, view=view)
kuby_logger.info(f"Sent role approval request for {member} to owner {owner}")
except Exception as e:
kuby_logger.error(f"Failed to send role approval request to owner: {e}")
async def on_member_remove(self, member):
kuby_logger.info(f"👋 {member} left {member.guild.name}")

View file

@ -232,24 +232,31 @@ class BugReport(commands.Cog):
current_labels_str = "Mis à jour"
try:
# Création de l'embed pour la mise à jour de statut
embed = disnake.Embed(
title="🛠️ Mise à jour de votre signalement !",
description=f"Le statut de votre rapport **{issue_title}** a été mis à jour par l'équipe.",
color=disnake.Color.blue()
# V2 Components migration
components = [
disnake.ui.Container(
disnake.ui.Section(
"🛠️ Mise à jour de votre signalement !",
accessory=disnake.ui.Thumbnail(self.bot.user.display_avatar.url)
),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(f"Le statut de votre rapport **{issue_title}** a été mis à jour par l'équipe."),
disnake.ui.TextDisplay(f"📌 **Nouveaux Labels / Statuts :** {current_labels_str}")
)
embed.add_field(name="Nouveaux Labels / Statuts", value=current_labels_str)
]
try:
await user.send(embed=embed)
# On logue systématiquement pour que le staff sache si ça a marché (utile en prod)
await user.send(components=components)
kuby_logger.info(f"Notification de label envoyée à {user} ({user.id}) pour l'issue #{issue_iid}")
if dev and dev.id != user.id:
await dev.send(f"✅ [SUIVI] L'utilisateur {user} ({user.id}) a bien reçu la notification de statut **{current_labels_str}** pour l'issue #{issue_iid} ({issue_title}).")
except disnake.Forbidden:
kuby_logger.warning(f"Impossible d'envoyer un MP à {user} ({user.id}) - MPs désactivés.")
except (disnake.Forbidden, disnake.HTTPException) as e:
if isinstance(e, disnake.Forbidden) or (isinstance(e, disnake.HTTPException) and e.code == 50007):
kuby_logger.warning(f"Impossible d'envoyer un MP à {user} ({user.id}) - MPs désactivés (Code: {getattr(e, 'code', 'Unknown')})")
if dev and dev.id != user.id:
await dev.send(f"⚠️ [DM BLOQUÉ] {user} ({user.id}) a ses MPs fermés. Impossible de notifier pour l'issue #{issue_iid}.")
else:
kuby_logger.error(f"Erreur HTTP lors de l'envoi du MP de statut à {user}: {e}")
except Exception as e:
kuby_logger.error(f"Erreur lors de l'envoi du MP de statut à {user}: {e}")
except Exception as e:
@ -261,23 +268,31 @@ class BugReport(commands.Cog):
if is_closing_now:
try:
embed = disnake.Embed(
title="🛠️ Bug / Suggestion Terminé(e) !",
description=f"Bonne nouvelle ! Votre signalement **{issue_title}** a été marqué comme terminé ou résolu.",
color=disnake.Color.green()
components = [
disnake.ui.Container(
disnake.ui.Section(
"🛠️ Bug / Suggestion Terminé(e) !",
accessory=disnake.ui.Thumbnail(self.bot.user.display_avatar.url)
),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(f"Bonne nouvelle ! Votre signalement **{issue_title}** a été marqué comme terminé ou résolu."),
disnake.ui.TextDisplay("🚀 **Prochaine étape :** La mise à jour arrive prochainement (ou est déjà là) !"),
disnake.ui.TextDisplay("✨ Merci pour votre aide !")
)
embed.add_field(name="Prochaine étape", value="La mise à jour arrive prochainement (ou est déjà là) !")
embed.set_footer(text="Merci pour votre aide !")
]
try:
await user.send(embed=embed)
await user.send(components=components)
kuby_logger.info(f"Notification de clôture envoyée à {user} ({user.id}) pour l'issue #{issue_iid}")
if dev and dev.id != user.id:
await dev.send(f"✅ [SUIVI] L'utilisateur {user} ({user.id}) a bien reçu la notification de CLÔTURE pour l'issue #{issue_iid}.")
except disnake.Forbidden:
except (disnake.Forbidden, disnake.HTTPException) as e:
if isinstance(e, disnake.Forbidden) or (isinstance(e, disnake.HTTPException) and e.code == 50007):
kuby_logger.warning(f"Impossible d'envoyer le MP de clôture à {user} ({user.id}).")
if dev and dev.id != user.id:
await dev.send(f"⚠️ [DM BLOQUÉ] Impossible d'annoncer la clôture à {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés).")
else:
kuby_logger.error(f"Erreur HTTP lors de l'envoi du MP de clôture à {user}: {e}")
except Exception as e:
kuby_logger.error(f"Erreur lors de l'envoi du MP de clôture à {user}: {e}")
except Exception as e:
@ -292,22 +307,32 @@ class BugReport(commands.Cog):
author_name = payload.get("user", {}).get("name", "Développeur")
try:
embed = disnake.Embed(
title="💬 Nouveau commentaire sur votre signalement",
description=f"Le développeur a répondu à votre rapport **{issue_title}** :\n\n>>> {note_body}",
color=disnake.Color.orange()
components = [
disnake.ui.Container(
disnake.ui.Section(
"💬 Nouveau commentaire sur votre signalement",
accessory=disnake.ui.Thumbnail(self.bot.user.display_avatar.url)
),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(f"Le développeur a répondu à votre rapport **{issue_title}** :"),
disnake.ui.TextDisplay(f">>> {note_body}"),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(f"✍️ **Par :** {author_name}")
)
embed.set_footer(text=f"Par {author_name}")
]
try:
await user.send(embed=embed)
await user.send(components=components)
kuby_logger.info(f"Commentaire GitLab envoyé à {user} ({user.id}) pour l'issue #{issue_iid}")
if dev and dev.id != user.id:
await dev.send(f"✅ [SUIVI] L'utilisateur {user} ({user.id}) a bien reçu votre commentaire pour l'issue #{issue_iid}.")
except disnake.Forbidden:
except (disnake.Forbidden, disnake.HTTPException) as e:
if isinstance(e, disnake.Forbidden) or (isinstance(e, disnake.HTTPException) and e.code == 50007):
kuby_logger.warning(f"Impossible d'envoyer le commentaire en MP à {user} ({user.id}).")
if dev and dev.id != user.id:
await dev.send(f"⚠️ [DM BLOQUÉ] Impossible d'envoyer votre commentaire à {user} ({user.id}) pour l'issue #{issue_iid} (DMs désactivés).")
else:
kuby_logger.error(f"Erreur HTTP lors de l'envoi du MP de commentaire à {user}: {e}")
except Exception as e:
kuby_logger.error(f"Erreur lors de l'envoi du MP de commentaire à {user}: {e}")
except Exception as e:

View file

@ -4,6 +4,10 @@ import json
import os
from typing import List, Optional, Dict
import datetime
import asyncio
import logging
kuby_logger = logging.getLogger("KubyBot")
# --- CONFIGURATION ---
CONV_SETTINGS_FILE = "data/convocation_settings.json"
@ -91,25 +95,33 @@ class Convocation(commands.Cog):
await interaction.followup.send("⚠️ Je n'ai pas pu retirer certains rôles (Permission refusée).", ephemeral=True)
# 2. Dynamic Lockdown: Loop through ALL channels
lockdown_count = 0
for channel in interaction.guild.channels:
semaphore = asyncio.Semaphore(10)
async def lock_channel(channel):
async with semaphore:
# On ignore le salon d'attente et ses parents s'il y en a
if attente_id and (channel.id == attente_id or (hasattr(channel, 'category') and channel.category and channel.category.id == attente_id)):
continue
return False
# Check existing override for the member
overwrites = channel.overwrites_for(membre)
if not overwrites.is_empty():
# Save existing override (as dict of pair of permission name and boolean/None)
# Save existing override
backup_data["overrides"][str(channel.id)] = {k: v for k, v in dict(overwrites).items() if v is not None}
# Apply Lockdown override for the member
try:
# Specific override for this member: View Channel = False
await channel.set_permissions(membre, view_channel=False, reason="Convocation Lockdown")
lockdown_count += 1
return True
except disnake.Forbidden:
pass # Skip if we can't touch this channel
return False
except Exception as e:
kuby_logger.warning(f"⚠️ [Convocation] Impossible de verrouiller {channel.name}: {e}")
return False
tasks = [lock_channel(c) for c in interaction.guild.channels]
results = await asyncio.gather(*tasks)
lockdown_count = sum(1 for r in results if r is True)
self.save_backup(guild_id, membre.id, backup_data)
@ -190,36 +202,16 @@ class Convocation(commands.Cog):
await interaction.followup.send("⚠️ Aucune donnée de convocation trouvée pour ce membre.", ephemeral=True)
return
# 1. Remove Convocation role
# 1. Remove Convocation role & Restore original roles FIRST
try:
if role_conv_id:
role_conv = interaction.guild.get_role(role_conv_id)
if role_conv and role_conv in membre.roles:
await membre.remove_roles(role_conv, reason="Fin de convocation")
# 2. Restore channel overrides
restore_count = 0
overrides = backup_data.get("overrides", {})
for channel in interaction.guild.channels:
chan_id_str = str(channel.id)
try:
if chan_id_str in overrides:
# Restore previous override
ov_data = overrides[chan_id_str]
overwrite = disnake.PermissionOverwrite(**ov_data)
await channel.set_permissions(membre, overwrite=overwrite, reason="Restauration fin de convocation")
restore_count += 1
else:
# Clear our lockdown override
# On ne vide que si un override membre existait (sinon on risque de supprimer des perms rôles ?)
# En fait set_permissions(member, overwrite=None) supprime l'entrée PERSONNELLE du membre.
await channel.set_permissions(membre, overwrite=None, reason="Fin de convocation - Cleanup")
except disnake.Forbidden:
pass
# 3. Restore roles
role_ids = backup_data.get("roles", [])
restored_roles = []
if role_ids:
restored_roles = []
for rid in role_ids:
role = interaction.guild.get_role(rid)
if role and role.position < interaction.guild.me.top_role.position:
@ -227,6 +219,42 @@ class Convocation(commands.Cog):
if restored_roles:
await membre.add_roles(*restored_roles, reason="Restauration fin de convocation")
except Exception as e:
kuby_logger.error(f"❌ [Convocation] Erreur lors de la restauration des rôles pour {membre}: {e}")
# 2. Restore channel overrides (Parallel)
semaphore = asyncio.Semaphore(10)
overrides = backup_data.get("overrides", {})
async def restore_channel(channel):
async with semaphore:
chan_id_str = str(channel.id)
try:
if chan_id_str in overrides:
# Restore previous override
ov_data = overrides[chan_id_str]
overwrite = disnake.PermissionOverwrite(**ov_data)
await channel.set_permissions(membre, overwrite=overwrite, reason="Restauration fin de convocation")
return "restored"
elif membre in channel.overwrites:
# Clear our lockdown override only if it exists
await channel.set_permissions(membre, overwrite=None, reason="Fin de convocation - Cleanup")
return "cleared"
except disnake.Forbidden:
return "forbidden"
except Exception as e:
kuby_logger.warning(f"⚠️ [Convocation] Erreur restauration {channel.name}: {e}")
return "error"
return "skipped"
tasks = [restore_channel(c) for c in interaction.guild.channels]
results = await asyncio.gather(*tasks)
restore_count = sum(1 for r in results if r == "restored")
clear_count = sum(1 for r in results if r == "cleared")
forbidden_count = sum(1 for r in results if r == "forbidden")
kuby_logger.info(f"🔓 [Convocation] Restauration terminée pour {membre}: {restore_count} restaurés, {clear_count} nettoyés, {forbidden_count} échecs permissions.")
self.clear_backup(guild_id, membre.id)

View file

@ -147,12 +147,16 @@ class Goodbye(commands.Cog):
# Utiliser le message personnalisé ou un par défaut
description = custom_message if custom_message else "Nous espérons vous revoir bientôt <a:rain:1317973615714504806>"
embed = disnake.Embed(title=f"{member} viens de nous quitter.",
description=description,
color=disnake.Color.dark_magenta())
embed.set_footer(text=f"Avait rejoint {joined_message}")
embed.set_image(url="attachment://goodbye.png")
await channel.send(embed=embed, file=disnake.File(img, filename="goodbye.png"))
components = [
disnake.ui.Container(
disnake.ui.TextDisplay(f"{member} viens de nous quitter."),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(description),
disnake.ui.TextDisplay(f"⌛ **Avait rejoint :** {joined_message}"),
disnake.ui.MediaGallery(disnake.MediaGalleryItem(url="attachment://goodbye.png"))
)
]
await channel.send(components=components, file=disnake.File(img, filename="goodbye.png"))
async def save_banner_attachment(self, attachment: disnake.Attachment, guild_id: int) -> str:
"""Sauvegarde l'attachment et retourne le chemin du fichier"""
@ -216,7 +220,12 @@ class Goodbye(commands.Cog):
if message: msg += f"\nMessage: {message}"
if banner_path: msg += f"\nBannière personnalisée activée."
await interaction.response.send_message(msg, ephemeral=True)
components = [
disnake.ui.Container(
disnake.ui.TextDisplay(msg)
)
]
await interaction.response.send_message(components=components, ephemeral=True)
def setup(bot):
bot.add_cog(Goodbye(bot))

195
commandes/invites.py Normal file
View file

@ -0,0 +1,195 @@
import disnake
from disnake.ext import commands
import sqlite3
import os
import logging
from typing import Dict, Optional
kuby_logger = logging.getLogger("KubyBot")
class Invites(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.db_path = os.path.join(os.path.dirname(__file__), "..", "config.db")
self.invite_cache = {} # {guild_id: {code: uses}}
self._init_db()
def _init_db(self):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS member_invites (
guild_id INTEGER,
member_id INTEGER,
inviter_id INTEGER,
invite_code TEXT,
PRIMARY KEY (guild_id, member_id)
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS invite_configs (
guild_id INTEGER PRIMARY KEY,
log_channel_id INTEGER
)
''')
conn.commit()
conn.close()
async def update_invite_cache(self, guild):
try:
invites = await guild.invites()
self.invite_cache[guild.id] = {invite.code: invite.uses for invite in invites}
except disnake.Forbidden:
pass
@commands.Cog.listener()
async def on_ready(self):
for guild in self.bot.guilds:
await self.update_invite_cache(guild)
kuby_logger.info("✅ [Invites] Cache des invitations initialisé")
# Debug: Lister toutes les commandes slash enregistrées
slash_cmds = [cmd.name for cmd in self.bot.slash_commands]
kuby_logger.info(f"🔍 [Debug] Commandes slash enregistrées : {slash_cmds}")
@commands.Cog.listener()
async def on_invite_create(self, invite):
if invite.guild.id not in self.invite_cache:
self.invite_cache[invite.guild.id] = {}
self.invite_cache[invite.guild.id][invite.code] = invite.uses
@commands.Cog.listener()
async def on_invite_delete(self, invite):
if invite.guild.id in self.invite_cache:
self.invite_cache[invite.guild.id].pop(invite.code, None)
@commands.Cog.listener()
async def on_member_join(self, member):
if member.bot: return
guild = member.guild
old_invites = self.invite_cache.get(guild.id, {})
try:
new_invites = await guild.invites()
except disnake.Forbidden:
return
used_invite = None
for invite in new_invites:
if invite.code in old_invites:
if invite.uses > old_invites[invite.code]:
used_invite = invite
break
elif invite.uses > 0:
# C'est une nouvelle invitation qui a été utilisée
used_invite = invite
break
# Mise à jour du cache
self.invite_cache[guild.id] = {invite.code: invite.uses for invite in new_invites}
if used_invite:
inviter = used_invite.inviter
if inviter:
self.record_invite(guild.id, member.id, inviter.id, used_invite.code)
kuby_logger.info(f"📥 [Invites] {member} invité par {inviter} ({used_invite.code})")
# Modular join message
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('SELECT log_channel_id FROM invite_configs WHERE guild_id = ?', (guild.id,))
result = cursor.fetchone()
conn.close()
if result:
log_channel = guild.get_channel(result[0])
if log_channel:
# Determine method
method = "invitation standard"
if guild.vanity_url_code and used_invite.code == guild.vanity_url_code:
method = "lien personnalisé"
elif used_invite.max_age == 0:
method = "invitation permanente"
elif used_invite.temporary:
method = "invitation temporaire"
elif used_invite.max_uses == 1:
method = "invitation à usage unique"
components = [
disnake.ui.Container(
disnake.ui.Section(
f"Nouveau membre via invitation",
accessory=disnake.ui.Thumbnail(member.display_avatar.url)
),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(f"👤 {member.mention} vient de nous rejoindre !"),
disnake.ui.TextDisplay(f"🤝 Grâce à {inviter.mention}"),
disnake.ui.TextDisplay(f"🔗 Via **{method}**")
)
]
await log_channel.send(components=components)
def record_invite(self, guild_id, member_id, inviter_id, code):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO member_invites (guild_id, member_id, inviter_id, invite_code)
VALUES (?, ?, ?, ?)
ON CONFLICT(guild_id, member_id) DO UPDATE SET inviter_id=excluded.inviter_id, invite_code=excluded.invite_code
''', (guild_id, member_id, inviter_id, code))
conn.commit()
conn.close()
def get_inviter_info(self, guild_id, member_id):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('SELECT inviter_id FROM member_invites WHERE guild_id = ? AND member_id = ?', (guild_id, member_id))
result = cursor.fetchone()
conn.close()
return result[0] if result else None
def get_invite_stats(self, guild_id, user_id):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('SELECT member_id FROM member_invites WHERE guild_id = ? AND inviter_id = ?', (guild_id, user_id))
invited_ids = [row[0] for row in cursor.fetchall()]
conn.close()
guild = self.bot.get_guild(guild_id)
if not guild: return 0, 0
current_present = 0
for mid in invited_ids:
if guild.get_member(mid):
current_present += 1
return len(invited_ids), current_present
@commands.slash_command(name="setinvitechannel", description="Définit le salon des logs d'invitations")
@commands.has_permissions(administrator=True)
async def set_invite_channel(self, interaction: disnake.ApplicationCommandInteraction, channel: disnake.TextChannel):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO invite_configs (guild_id, log_channel_id)
VALUES (?, ?)
ON CONFLICT(guild_id) DO UPDATE SET log_channel_id=excluded.log_channel_id
''', (interaction.guild_id, channel.id))
conn.commit()
conn.close()
components = [
disnake.ui.Container(
disnake.ui.TextDisplay(f"✅ Salon des logs d'invitations défini sur {channel.mention}")
)
]
await interaction.response.send_message(components=components, ephemeral=True)
@commands.command(name="invitations_debug")
async def invites_prefix(self, ctx, user: disnake.Member = None):
user = user or ctx.author
total, current = self.get_invite_stats(ctx.guild.id, user.id)
await ctx.send(f"📊 **{user.display_name}** : {total} total, {current} présents.")
def setup(bot):
bot.add_cog(Invites(bot))

View file

@ -6,118 +6,63 @@ from datetime import datetime, timedelta
import re
class ModReasonModal(disnake.ui.Modal):
def __init__(self, parent_view, action_type):
super().__init__(title=f"Raison pour {action_type}", components=[])
self.parent_view = parent_view
def __init__(self, cog, action_type, member):
self.cog = cog
self.action_type = action_type
self.member = member
self.reason = disnake.ui.TextInput(
self.reason_input = disnake.ui.TextInput(
label="Raison",
placeholder="Entrez la raison ici...",
required=True,
max_length=500
max_length=500,
custom_id="reason"
)
self.append_component(self.reason)
components = [self.reason_input]
if action_type == "TIMEOUT":
self.duration = disnake.ui.TextInput(
self.duration_input = disnake.ui.TextInput(
label="Durée (ex: 1h, 30m, 1d)",
placeholder="1h",
required=True,
max_length=10
max_length=10,
custom_id="duration"
)
self.append_component(self.duration)
components.append(self.duration_input)
async def callback(self, interaction: disnake.Interaction):
reason = self.reason.value
member = self.parent_view.member
super().__init__(title=f"Raison pour {action_type}", components=components)
cog = interaction.bot.get_cog("Moderation")
if not cog:
return await interaction.response.send_message("❌ Erreur interne.", ephemeral=True)
async def callback(self, interaction: disnake.ModalInteraction):
reason = interaction.text_values["reason"]
member = self.member
if self.action_type == "WARN":
await cog.warn(interaction, member, reason)
await self.cog.warn(interaction, member, reason)
elif self.action_type == "TIMEOUT":
await cog.timeout(interaction, member, self.duration.value, reason)
duration = interaction.text_values["duration"]
await self.cog.timeout(interaction, member, duration, reason)
elif self.action_type == "KICK":
await cog.kick(interaction, member, reason)
await self.cog.kick(interaction, member, reason)
elif self.action_type == "BAN":
await cog.ban(interaction, member, reason)
await self.cog.ban(interaction, member, reason)
await self.parent_view.update_panel(interaction)
class ModPanelView(disnake.ui.View):
def __init__(self, member, moderator, db_path):
super().__init__(timeout=120)
self.member = member
self.moderator = moderator
self.db_path = db_path
async def update_panel(self, interaction: disnake.Interaction):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('SELECT COUNT(*) FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "WARN" AND status = "ACTIVE"', (interaction.guild_id, self.member.id))
warn_count = cursor.fetchone()[0]
conn.close()
embed = disnake.Embed(
title=f"🛡️ Panel de Modération - {self.member.name}",
description=f"Gestion de l'utilisateur {self.member.mention}",
color=disnake.Color.blue(),
timestamp=datetime.now()
)
embed.set_thumbnail(url=self.member.display_avatar.url)
embed.add_field(name="👤 Utilisateur", value=f"ID: `{self.member.id}`\nTag: `{self.member}`", inline=True)
embed.add_field(name="⚠️ Avertissements", value=f"Actifs: **{warn_count}**", inline=True)
embed.add_field(name="📅 Dates", value=f"Rejoint: <t:{int(self.member.joined_at.timestamp())}:R>\nCréé: <t:{int(self.member.created_at.timestamp())}:R>", inline=False)
if not interaction.response.is_done():
await interaction.response.edit_message(embed=embed, view=self)
else:
await interaction.edit_original_response(embed=embed, view=self)
@disnake.ui.button(label="Avertir", style=disnake.ButtonStyle.secondary, emoji="⚠️")
async def warn_btn(self, button: disnake.ui.Button, interaction: disnake.Interaction):
await interaction.response.send_modal(ModReasonModal(self, "WARN"))
@disnake.ui.button(label="Timeout", style=disnake.ButtonStyle.secondary, emoji="")
async def timeout_btn(self, button: disnake.ui.Button, interaction: disnake.Interaction):
await interaction.response.send_modal(ModReasonModal(self, "TIMEOUT"))
@disnake.ui.button(label="Expulser", style=disnake.ButtonStyle.secondary, emoji="👢")
async def kick_btn(self, button: disnake.ui.Button, interaction: disnake.Interaction):
await interaction.response.send_modal(ModReasonModal(self, "KICK"))
@disnake.ui.button(label="Bannir", style=disnake.ButtonStyle.danger, emoji="🔨")
async def ban_btn(self, button: disnake.ui.Button, interaction: disnake.Interaction):
await interaction.response.send_modal(ModReasonModal(self, "BAN"))
@disnake.ui.button(label="Historique", style=disnake.ButtonStyle.primary, emoji="📜")
async def history_btn(self, button: disnake.ui.Button, interaction: disnake.Interaction):
cog = interaction.bot.get_cog("Moderation")
await cog.history(interaction, self.member)
@disnake.ui.button(label="Clear Warns", style=disnake.ButtonStyle.danger, emoji="🧹")
async def clear_btn(self, button: disnake.ui.Button, interaction: disnake.Interaction):
cog = interaction.bot.get_cog("Moderation")
await cog.clearwarns(interaction, self.member, reason="Nettoyage via Panel")
await self.update_panel(interaction)
await self.cog.send_modpanel_v2(interaction, member, edit=True)
class ModConfigModal(disnake.ui.Modal):
def __init__(self, field_name, current_value):
super().__init__(components=[], title=f"Modifier {field_name}")
self.field_name = field_name
self.input = disnake.ui.TextInput(
self.input_field = disnake.ui.TextInput(
label=field_name,
value=str(current_value),
placeholder="Entrez une valeur numérique...",
required=True
required=True,
custom_id="value"
)
self.append_component(self.input)
super().__init__(title=f"Modifier {field_name}", components=[self.input_field])
async def callback(self, interaction: disnake.Interaction):
value = self.input.value
async def callback(self, interaction: disnake.ModalInteraction):
value = interaction.text_values["value"]
cog = interaction.bot.get_cog("Moderation")
mapping = {
@ -137,62 +82,8 @@ class ModConfigModal(disnake.ui.Modal):
conn.commit()
conn.close()
await interaction.response.send_message(f"{self.field_name} mis à jour sur **{value}**.", ephemeral=True)
class ModConfigView(disnake.ui.View):
def __init__(self, guild_id, db_path):
super().__init__(timeout=60)
self.guild_id = guild_id
self.db_path = db_path
@disnake.ui.button(label="Log Channel", style=disnake.ButtonStyle.primary, emoji="📁")
async def log_btn(self, button: disnake.ui.Button, interaction: disnake.Interaction):
view = disnake.ui.View()
select = disnake.ui.ChannelSelect(
placeholder="Sélectionnez le salon de logs...",
channel_types=[disnake.ChannelType.text]
)
async def select_callback(interaction: disnake.Interaction):
channel = select.values[0]
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("UPDATE mod_config SET log_channel_id = ? WHERE guild_id = ?", (channel.id, interaction.guild_id))
conn.commit(); conn.close()
await interaction.response.send_message(f"✅ Salon de logs défini sur {channel.mention}", ephemeral=True)
select.callback = select_callback
view.add_item(select)
await interaction.response.send_message("Choisissez le salon de logs :", view=view, ephemeral=True)
@disnake.ui.button(label="Paliers Warns", style=disnake.ButtonStyle.secondary, emoji="⚖️")
async def limits_btn(self, button: disnake.ui.Button, interaction: disnake.Interaction):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("SELECT warn_limit_timeout, warn_limit_kick, warn_limit_ban FROM mod_config WHERE guild_id = ?", (self.guild_id,))
res = cursor.fetchone(); conn.close()
view = disnake.ui.View()
btn_t = disnake.ui.Button(label=f"Timeout ({res[0]})", style=disnake.ButtonStyle.secondary)
btn_t.callback = lambda i: i.response.send_modal(ModConfigModal("Limite Timeout", res[0]))
btn_k = disnake.ui.Button(label=f"Kick ({res[1]})", style=disnake.ButtonStyle.secondary)
btn_k.callback = lambda i: i.response.send_modal(ModConfigModal("Limite Kick", res[1]))
btn_b = disnake.ui.Button(label=f"Ban ({res[2]})", style=disnake.ButtonStyle.secondary)
btn_b.callback = lambda i: i.response.send_modal(ModConfigModal("Limite Ban", res[2]))
view.add_item(btn_t); view.add_item(btn_k); view.add_item(btn_b)
await interaction.response.send_message("Quelle limite modifier ?", view=view, ephemeral=True)
@disnake.ui.button(label="Durée Timeout", style=disnake.ButtonStyle.secondary, emoji="⏱️")
async def duration_btn(self, button: disnake.ui.Button, interaction: disnake.Interaction):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("SELECT timeout_duration FROM mod_config WHERE guild_id = ?", (self.guild_id,))
res = cursor.fetchone(); conn.close()
await interaction.response.send_modal(ModConfigModal("Durée Timeout (s)", res[0]))
await interaction.response.send_message(f"{self.field_name} mis à jour.", ephemeral=True)
await cog.send_modconfig_v2(interaction, edit=True)
class Moderation(commands.Cog):
def __init__(self, bot):
@ -204,7 +95,17 @@ class Moderation(commands.Cog):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''CREATE TABLE IF NOT EXISTS sanctions (id INTEGER PRIMARY KEY AUTOINCREMENT, guild_id INTEGER, user_id INTEGER, moderator_id INTEGER, type TEXT, reason TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, duration INTEGER, status TEXT DEFAULT 'ACTIVE')''')
cursor.execute('''CREATE TABLE IF NOT EXISTS mod_config (guild_id INTEGER PRIMARY KEY, log_channel_id INTEGER, warn_limit_timeout INTEGER DEFAULT 3, warn_limit_kick INTEGER DEFAULT 5, warn_limit_ban INTEGER DEFAULT 10, timeout_duration INTEGER DEFAULT 3600)''')
cursor.execute('''CREATE TABLE IF NOT EXISTS mod_config (
guild_id INTEGER PRIMARY KEY,
log_channel_id INTEGER,
board_channel_id INTEGER,
warn_limit_timeout INTEGER DEFAULT 3,
warn_limit_kick INTEGER DEFAULT 5,
warn_limit_ban INTEGER DEFAULT 10,
timeout_duration INTEGER DEFAULT 3600
)''')
try: cursor.execute("ALTER TABLE mod_config ADD COLUMN board_channel_id INTEGER")
except: pass
conn.commit(); conn.close()
def parse_duration(self, duration_str: str) -> int:
@ -215,110 +116,226 @@ class Moderation(commands.Cog):
for value, unit in matches: total_seconds += int(value) * units[unit]
return total_seconds
async def send_mod_log(self, guild, embed):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('SELECT log_channel_id FROM mod_config WHERE guild_id = ?', (guild.id,))
result = cursor.fetchone(); conn.close()
if result and result[0]:
log_channel = guild.get_channel(result[0])
if log_channel: await log_channel.send(embed=embed)
def _type_to_emoji(self, s_type: str) -> str:
return {"WARN": "⚠️", "TIMEOUT": "", "KICK": "👢", "BAN": "🔨"}.get(s_type, "🛡️")
@commands.slash_command(name="modpanel", description="Ouvre le panel de modération pour un membre")
def build_sanction_board_components(self, *, s_type: str, user, moderator, reason, timestamp, s_id, duration=None) -> list:
ts_int = int(datetime.fromisoformat(str(timestamp)).timestamp()) if not isinstance(timestamp, datetime) else int(timestamp.timestamp())
emoji = self._type_to_emoji(s_type)
children = [
disnake.ui.Section(f"{emoji} **SANCTION : {s_type}**", accessory=disnake.ui.Thumbnail(user.display_avatar.url)),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(f"- **Membre sanctionné** : {user.mention}"),
disnake.ui.TextDisplay(f"- **Modérateur** : {moderator.mention}"),
disnake.ui.TextDisplay(f"- **Raison** : “ *{reason if reason else 'Aucune raison spécifiée'}* ”"),
disnake.ui.TextDisplay(f"- **Date** : <t:{ts_int}:F>")
]
if s_type == "TIMEOUT" and duration:
children.append(disnake.ui.TextDisplay(f"- **Durée** : `{duration}s`"))
children.extend([
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(f"🆔 **ID** : `{s_id}`")
])
return [disnake.ui.Container(*children)]
async def send_mod_log(self, guild, components):
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
cursor.execute('SELECT log_channel_id FROM mod_config WHERE guild_id = ?', (guild.id,))
res = cursor.fetchone(); conn.close()
if res and res[0]:
chan = guild.get_channel(res[0])
if chan: await chan.send(components=components)
async def send_sanction_board(self, guild, components):
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
cursor.execute('SELECT board_channel_id FROM mod_config WHERE guild_id = ?', (guild.id,))
res = cursor.fetchone(); conn.close()
if res and res[0]:
chan = guild.get_channel(res[0])
if chan: await chan.send(components=components)
async def send_modpanel_v2(self, interaction: disnake.Interaction, member: disnake.Member, edit=False):
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
cursor.execute('SELECT COUNT(*) FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "WARN" AND status = "ACTIVE"', (interaction.guild_id, member.id))
warn_count = cursor.fetchone()[0]
cursor.execute('SELECT type, reason, timestamp FROM sanctions WHERE guild_id = ? AND user_id = ? AND status = "ACTIVE" ORDER BY timestamp DESC LIMIT 3', (interaction.guild_id, member.id))
rows = cursor.fetchall(); conn.close()
children = [
disnake.ui.Section(f"🛡️ Panel : {member.name}", accessory=disnake.ui.Thumbnail(member.display_avatar.url)),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(f"👤 **Membre :** {member.mention} (`{member.id}`)"),
disnake.ui.TextDisplay(f"⚠️ **Avertissements actifs :** {warn_count}"),
disnake.ui.Separator(divider=True),
disnake.ui.Section("⚠️ Infliger un avertissement", accessory=disnake.ui.Button(label="Warn", style=disnake.ButtonStyle.secondary, custom_id=f"modpan_warn:{member.id}")),
disnake.ui.Section("⏳ Mettre en sourdine", accessory=disnake.ui.Button(label="Timeout", style=disnake.ButtonStyle.secondary, custom_id=f"modpan_timeout:{member.id}")),
disnake.ui.Section("👢 Expulser du serveur", accessory=disnake.ui.Button(label="Kick", style=disnake.ButtonStyle.secondary, custom_id=f"modpan_kick:{member.id}")),
disnake.ui.Section("🔨 Bannir définitivement", accessory=disnake.ui.Button(label="Ban", style=disnake.ButtonStyle.danger, custom_id=f"modpan_ban:{member.id}")),
disnake.ui.Separator(divider=True),
disnake.ui.Section("🧹 Réinitialiser les warns", accessory=disnake.ui.Button(label="Clear", style=disnake.ButtonStyle.danger, custom_id=f"modpan_clear:{member.id}"))
]
if rows:
children.append(disnake.ui.Separator(divider=True))
children.append(disnake.ui.TextDisplay("📌 **Dernières sanctions :**"))
for t, r, ts in rows:
ts_int = int(datetime.fromisoformat(str(ts)).timestamp()) if not isinstance(ts, datetime) else int(ts.timestamp())
children.append(disnake.ui.TextDisplay(f"{self._type_to_emoji(t)} **{t}** — {r} (<t:{ts_int}:R>)"))
components = [disnake.ui.Container(*children)]
if edit:
if not interaction.response.is_done(): await interaction.response.edit_message(content=None, components=components)
else: await interaction.edit_original_response(content=None, components=components)
else:
await interaction.response.send_message(components=components, ephemeral=True)
@commands.slash_command(name="modpanel", description="Ouvre le panel de modération")
@commands.has_permissions(moderate_members=True)
async def modpanel(self, interaction: disnake.ApplicationCommandInteraction, member: disnake.Member):
if member.top_role >= interaction.user.top_role and interaction.user.id != interaction.guild.owner_id:
return await interaction.response.send_message("❌ Impossible de gérer ce membre.", ephemeral=True)
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('SELECT COUNT(*) FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "WARN" AND status = "ACTIVE"', (interaction.guild_id, member.id))
warn_count = cursor.fetchone()[0]; conn.close()
embed = disnake.Embed(title=f"🛡️ Panel de Modération - {member.name}", description=f"Gestion de {member.mention}", color=disnake.Color.blue(), timestamp=datetime.now())
embed.set_thumbnail(url=member.display_avatar.url)
embed.add_field(name="👤 Utilisateur", value=f"ID: `{member.id}`", inline=True)
embed.add_field(name="⚠️ Avertissements", value=f"Actifs: **{warn_count}**", inline=True)
view = ModPanelView(member, interaction.user, self.db_path)
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
return await interaction.response.send_message("❌ Permissions insuffisantes.", ephemeral=True)
await self.send_modpanel_v2(interaction, member)
async def send_modconfig_v2(self, interaction: disnake.Interaction, edit=False):
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
cursor.execute('SELECT log_channel_id, board_channel_id, warn_limit_timeout, warn_limit_kick, warn_limit_ban, timeout_duration FROM mod_config WHERE guild_id = ?', (interaction.guild_id,))
res = cursor.fetchone(); conn.close()
if not res: return
children = [
disnake.ui.Section("⚙️ Configuration Modération", accessory=disnake.ui.Thumbnail(interaction.guild.icon.url if interaction.guild.icon else None)),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(f"📁 **Logs internes :** <#{res[0]}>" if res[0] else "📁 **Logs :** Non défini"),
disnake.ui.TextDisplay(f"📢 **Affichage public :** <#{res[1]}>" if res[1] else "📢 **Affichage :** Non défini"),
disnake.ui.TextDisplay(f"⚖️ **Paliers :** T:{res[2]} | K:{res[3]} | B:{res[4]}"),
disnake.ui.TextDisplay(f"⏱️ **Timeout auto :** {res[5]}s"),
disnake.ui.Separator(divider=True),
disnake.ui.Section("📁 Salon des logs", accessory=disnake.ui.Button(label="Logs", style=disnake.ButtonStyle.primary, custom_id="modcfg_logs")),
disnake.ui.Section("📢 Salon d'affichage", accessory=disnake.ui.Button(label="Public", style=disnake.ButtonStyle.primary, custom_id="modcfg_board")),
disnake.ui.Section("⚖️ Modifier paliers", accessory=disnake.ui.Button(label="Paliers", style=disnake.ButtonStyle.secondary, custom_id="modcfg_limits")),
disnake.ui.Section("⏱️ Durée timeout", accessory=disnake.ui.Button(label="Durée", style=disnake.ButtonStyle.secondary, custom_id="modcfg_duration"))
]
components = [disnake.ui.Container(*children)]
if edit:
if not interaction.response.is_done(): await interaction.response.edit_message(content=None, components=components)
else: await interaction.edit_original_response(content=None, components=components)
else:
await interaction.response.send_message(components=components, ephemeral=True)
@commands.slash_command(name="modconfig", description="Configuration de la modération")
@commands.has_permissions(administrator=True)
async def modconfig(self, interaction: disnake.ApplicationCommandInteraction):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
cursor.execute('INSERT OR IGNORE INTO mod_config (guild_id) VALUES (?)', (interaction.guild_id,))
cursor.execute('SELECT log_channel_id, warn_limit_timeout, warn_limit_kick, warn_limit_ban, timeout_duration FROM mod_config WHERE guild_id = ?', (interaction.guild_id,))
res = cursor.fetchone(); conn.commit(); conn.close()
embed = disnake.Embed(title="⚙️ Configuration Modération", description="Modifiez les paramètres ci-dessous.", color=disnake.Color.blue())
embed.add_field(name="📁 Logs", value=f"<#{res[0]}>" if res[0] else "Non défini", inline=False)
embed.add_field(name="⚖️ Paliers", value=f"Timeout: **{res[1]}**, Kick: **{res[2]}**, Ban: **{res[3]}**", inline=False)
embed.add_field(name="⏱️ Durée Timeout Auto", value=f"**{res[4]}s**", inline=False)
view = ModConfigView(interaction.guild_id, self.db_path)
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
conn.commit(); conn.close()
await self.send_modconfig_v2(interaction)
async def warn(self, interaction: disnake.Interaction, member: disnake.Member, reason: str):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
@commands.Cog.listener("on_message_interaction")
async def on_mod_interaction(self, interaction: disnake.MessageInteraction):
cid = interaction.data.custom_id
if not cid: return
if cid.startswith("modpan_"):
action, mid = cid.replace("modpan_", "").split(":")
member = interaction.guild.get_member(int(mid))
if not member: return await interaction.response.send_message("❌ Membre introuvable.", ephemeral=True)
if action == "clear":
await self.clearwarns(interaction, member, "Nettoyage Panel")
await self.send_modpanel_v2(interaction, member, edit=True)
else:
await interaction.response.send_modal(ModReasonModal(self, action.upper(), member))
elif cid.startswith("modcfg_"):
act = cid.replace("modcfg_", "")
if act in ["logs", "board"]:
col = "log_channel_id" if act == "logs" else "board_channel_id"
view = disnake.ui.View()
sel = disnake.ui.ChannelSelect(placeholder="Choisir salon...", channel_types=[disnake.ChannelType.text])
async def sel_cb(i):
c = sel.values[0]
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
cursor.execute(f"UPDATE mod_config SET {col} = ? WHERE guild_id = ?", (c.id, i.guild_id))
conn.commit(); conn.close()
await i.response.send_message(f"✅ Salon mis à jour.", ephemeral=True)
await self.send_modconfig_v2(interaction, edit=True)
sel.callback = sel_cb; view.add_item(sel)
await interaction.response.send_message(f"Sélectionnez le salon pour {act} :", view=view, ephemeral=True)
elif act == "limits":
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
cursor.execute("SELECT warn_limit_timeout, warn_limit_kick, warn_limit_ban FROM mod_config WHERE guild_id = ?", (interaction.guild_id,))
res = cursor.fetchone(); conn.close()
view = disnake.ui.View()
b1 = disnake.ui.Button(label=f"Timeout ({res[0]})"); b1.callback = lambda i: i.response.send_modal(ModConfigModal("Limite Timeout", res[0]))
b2 = disnake.ui.Button(label=f"Kick ({res[1]})"); b2.callback = lambda i: i.response.send_modal(ModConfigModal("Limite Kick", res[1]))
b3 = disnake.ui.Button(label=f"Ban ({res[2]})"); b3.callback = lambda i: i.response.send_modal(ModConfigModal("Limite Ban", res[2]))
view.add_item(b1); view.add_item(b2); view.add_item(b3)
await interaction.response.send_message("Modifier quel palier ?", view=view, ephemeral=True)
elif act == "duration":
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
cursor.execute("SELECT timeout_duration FROM mod_config WHERE guild_id = ?", (interaction.guild_id,))
res = cursor.fetchone(); conn.close()
await interaction.response.send_modal(ModConfigModal("Durée Timeout (s)", res[0]))
async def warn(self, interaction, member, reason):
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
cursor.execute('INSERT INTO sanctions (guild_id, user_id, moderator_id, type, reason) VALUES (?, ?, ?, ?, ?)', (interaction.guild_id, member.id, interaction.user.id, 'WARN', reason))
cursor.execute('SELECT COUNT(*) FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "WARN" AND status = "ACTIVE"', (interaction.guild_id, member.id))
warn_count = cursor.fetchone()[0]
count = cursor.fetchone()[0]
cursor.execute('SELECT warn_limit_timeout, warn_limit_kick, warn_limit_ban, timeout_duration FROM mod_config WHERE guild_id = ?', (interaction.guild_id,))
config = cursor.fetchone(); conn.commit(); conn.close()
if not interaction.response.is_done(): await interaction.response.send_message(f"{member.mention} averti ({warn_count}).", ephemeral=True)
else: await interaction.followup.send(f"{member.mention} averti ({warn_count}).", ephemeral=True)
await self.send_mod_log(interaction.guild, disnake.Embed(title="⚠️ Avertissement", description=f"Membre: {member.mention}\nRaison: {reason}\nTotal: {warn_count}", color=disnake.Color.orange()))
cfg = cursor.fetchone()
cursor.execute('SELECT id, timestamp FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "WARN" ORDER BY id DESC LIMIT 1', (interaction.guild_id, member.id))
s_id, ts = cursor.fetchone(); conn.commit(); conn.close()
if not interaction.response.is_done(): await interaction.response.send_message(f"{member.mention} averti ({count}).", ephemeral=True)
await self.send_mod_log(interaction.guild, [disnake.ui.Container(disnake.ui.Section(f"⚠️ Warn : {member}", accessory=disnake.ui.Thumbnail(member.display_avatar.url)), disnake.ui.Separator(divider=True), disnake.ui.TextDisplay(f"Raison: {reason}"), disnake.ui.TextDisplay(f"Total: {count}"))])
await self.send_sanction_board(interaction.guild, self.build_sanction_board_components(s_type="WARN", user=member, moderator=interaction.user, reason=reason, timestamp=ts, s_id=s_id))
try: await member.send(f"⚠️ Avertissement sur **{interaction.guild.name}**\nRaison: {reason}")
except: pass
if config:
l_t, l_k, l_b, dur = config
if warn_count >= l_b: await member.ban(reason=f"Auto: {warn_count} warns")
elif warn_count >= l_k: await member.kick(reason=f"Auto: {warn_count} warns")
elif warn_count >= l_t: await member.timeout(duration=timedelta(seconds=dur), reason=f"Auto: {warn_count} warns")
if cfg:
lt, lk, lb, dur = cfg
if count >= lb: await member.ban(reason=f"Auto: {count} warns")
elif count >= lk: await member.kick(reason=f"Auto: {count} warns")
elif count >= lt: await member.timeout(duration=timedelta(seconds=dur), reason=f"Auto: {count} warns")
async def timeout(self, interaction: disnake.Interaction, member: disnake.Member, duration_str: str, reason: str):
async def timeout(self, interaction, member, duration_str, reason):
secs = self.parse_duration(duration_str)
if secs <= 0: return await interaction.response.send_message("❌ Durée invalide.", ephemeral=True)
await member.timeout(duration=timedelta(seconds=secs), reason=reason)
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
cursor.execute('INSERT INTO sanctions (guild_id, user_id, moderator_id, type, reason, duration) VALUES (?, ?, ?, ?, ?, ?)', (interaction.guild_id, member.id, interaction.user.id, 'TIMEOUT', reason, secs))
conn.commit(); conn.close()
if not interaction.response.is_done(): await interaction.response.send_message(f"{member.mention} mis en sourdine.", ephemeral=True)
else: await interaction.followup.send(f"{member.mention} mis en sourdine.", ephemeral=True)
cursor.execute('SELECT id, timestamp FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "TIMEOUT" ORDER BY id DESC LIMIT 1', (interaction.guild_id, member.id))
s_id, ts = cursor.fetchone(); conn.commit(); conn.close()
if not interaction.response.is_done(): await interaction.response.send_message(f"{member.mention} timeout.", ephemeral=True)
await self.send_sanction_board(interaction.guild, self.build_sanction_board_components(s_type="TIMEOUT", user=member, moderator=interaction.user, reason=reason, timestamp=ts, s_id=s_id, duration=secs))
async def kick(self, interaction: disnake.Interaction, member: disnake.Member, reason: str):
async def kick(self, interaction, member, reason):
await member.kick(reason=reason)
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
cursor.execute('INSERT INTO sanctions (guild_id, user_id, moderator_id, type, reason) VALUES (?, ?, ?, ?, ?)', (interaction.guild_id, member.id, interaction.user.id, 'KICK', reason))
conn.commit(); conn.close()
cursor.execute('SELECT id, timestamp FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "KICK" ORDER BY id DESC LIMIT 1', (interaction.guild_id, member.id))
s_id, ts = cursor.fetchone(); conn.commit(); conn.close()
if not interaction.response.is_done(): await interaction.response.send_message(f"{member.name} expulsé.", ephemeral=True)
else: await interaction.followup.send(f"{member.name} expulsé.", ephemeral=True)
await self.send_sanction_board(interaction.guild, self.build_sanction_board_components(s_type="KICK", user=member, moderator=interaction.user, reason=reason, timestamp=ts, s_id=s_id))
async def ban(self, interaction: disnake.Interaction, user: disnake.User, reason: str):
async def ban(self, interaction, user, reason):
await interaction.guild.ban(user, reason=reason)
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
cursor.execute('INSERT INTO sanctions (guild_id, user_id, moderator_id, type, reason) VALUES (?, ?, ?, ?, ?)', (interaction.guild_id, user.id, interaction.user.id, 'BAN', reason))
conn.commit(); conn.close()
cursor.execute('SELECT id, timestamp FROM sanctions WHERE guild_id = ? AND user_id = ? AND type = "BAN" ORDER BY id DESC LIMIT 1', (interaction.guild_id, user.id))
s_id, ts = cursor.fetchone(); conn.commit(); conn.close()
if not interaction.response.is_done(): await interaction.response.send_message(f"{user.name} banni.", ephemeral=True)
else: await interaction.followup.send(f"{user.name} banni.", ephemeral=True)
await self.send_sanction_board(interaction.guild, self.build_sanction_board_components(s_type="BAN", user=user, moderator=interaction.user, reason=reason, timestamp=ts, s_id=s_id))
async def history(self, interaction: disnake.Interaction, user: disnake.User):
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
cursor.execute('SELECT id, type, reason, timestamp FROM sanctions WHERE guild_id = ? AND user_id = ? ORDER BY timestamp DESC', (interaction.guild_id, user.id))
results = cursor.fetchall(); conn.close()
if not results:
if not interaction.response.is_done(): return await interaction.response.send_message(" Aucun historique.", ephemeral=True)
else: return await interaction.followup.send(" Aucun historique.", ephemeral=True)
embed = disnake.Embed(title=f"📜 Historique de {user.name}", color=disnake.Color.blue())
for s_id, s_type, reason, ts in results[:10]:
embed.add_field(name=f"#{s_id} - {s_type}", value=f"**Date:** {ts}\n**Raison:** {reason}", inline=False)
if not interaction.response.is_done(): await interaction.response.send_message(embed=embed, ephemeral=True)
else: await interaction.followup.send(embed=embed, ephemeral=True)
async def clearwarns(self, interaction: disnake.Interaction, member: disnake.Member, reason: str):
async def clearwarns(self, interaction, member, reason):
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
cursor.execute('UPDATE sanctions SET status = "REVOKED" WHERE guild_id = ? AND user_id = ? AND type = "WARN"', (interaction.guild_id, member.id))
conn.commit(); conn.close()
if not interaction.response.is_done(): await interaction.response.send_message(f"✅ Warns de {member.name} effacés.", ephemeral=True)
else: await interaction.followup.send(f"✅ Warns de {member.name} effacés.", ephemeral=True)
if not interaction.response.is_done(): await interaction.response.send_message(f"✅ Warns effacés.", ephemeral=True)
def setup(bot):
bot.add_cog(Moderation(bot))
def setup(bot): bot.add_cog(Moderation(bot))

View file

@ -137,7 +137,7 @@ class FeedbackModal(disnake.ui.Modal):
required=True,
max_length=1000
)
self.append_component(self.comment)
self.add_item(self.comment)
async def callback(self, interaction: disnake.Interaction):
self.view.comment = self.comment.value

View file

@ -5,6 +5,7 @@ import aiohttp
import io
import os
import logging
import asyncio
from PIL import Image, ImageDraw, ImageFont
kuby_logger = logging.getLogger("KubyBot")
@ -132,7 +133,7 @@ class Welcome(commands.Cog):
kuby_logger.debug(f"🔍 [Welcome] Config récupérée: DM={bool(welcome_dm_text)}, Channel={welcome_channel}")
# 1. Gestion du DM
if welcome_dm_text:
if welcome_dm_text and not member.bot:
kuby_logger.info(f"✉️ [Welcome] Tentative d'envoi de DM à {member.name}")
try:
formatted_message = welcome_dm_text.format(
@ -149,6 +150,8 @@ class Welcome(commands.Cog):
except disnake.HTTPException as e:
if e.code == 40003: # Rate limited (Opening DMs too fast)
kuby_logger.warning(f"⏳ [Welcome] Limite de débit Discord atteinte pour les DMs : {member.name} n'a pas reçu son message.")
elif e.code == 50007: # Cannot send messages to this user
kuby_logger.warning(f"🚫 [Welcome] Impossible d'envoyer le DM à {member.name} (DMs fermés ou utilisateur non joignable)")
else:
kuby_logger.error(f"❌ [Welcome] Erreur HTTP lors de l'envoi du DM à {member.name}: {e}")
except Exception as e:
@ -169,18 +172,33 @@ class Welcome(commands.Cog):
banner_background=banner_background
)
if img:
# Attendre un peu que le Cog d'invitations traite le join
await asyncio.sleep(1)
invites_cog = self.bot.get_cog("Invites")
inviter_text = ""
if invites_cog:
inviter_id = invites_cog.get_inviter_info(member.guild.id, member.id)
if inviter_id:
inviter = member.guild.get_member(inviter_id) or await self.bot.fetch_user(inviter_id)
total, current = invites_cog.get_invite_stats(member.guild.id, inviter_id)
inviter_text = f"\n\n**Invité par :** {inviter.mention} ({current} invitations)"
# Utiliser le message personnalisé ou un message par défaut
channel_description = welcome_channel_message.format(
channel_description = (welcome_channel_message.format(
username=member.name,
servername=member.guild.name,
member_count=member.guild.member_count
) if welcome_channel_message else "Bienvenue dans le serveur !\nSi vous cherchez des RolePlay de qualité,\nVous êtes au bon endroit. <a:sip:1316891821858619452>"
) if welcome_channel_message else "Bienvenue dans le serveur !\nSi vous cherchez des RolePlay de qualité,\nVous êtes au bon endroit. <a:sip:1316891821858619452>") + inviter_text
embed = disnake.Embed(title=f"Merci d'accueillir {member} 🤝",
description=channel_description,
color=disnake.Color.dark_purple())
embed.set_image(url="attachment://welcome.png")
await channel.send(content=member.mention, embed=embed, file=disnake.File(img, filename="welcome.png"))
components = [
disnake.ui.Container(
disnake.ui.TextDisplay(f"Merci d'accueillir {member} 🤝"),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(channel_description),
disnake.ui.MediaGallery(disnake.MediaGalleryItem(url="attachment://welcome.png"))
)
]
await channel.send(content=member.mention, components=components, file=disnake.File(img, filename="welcome.png"))
async def save_banner_attachment(self, attachment: disnake.Attachment, guild_id: int) -> str:
"""Sauvegarde l'attachment et retourne le chemin du fichier"""
@ -243,6 +261,36 @@ class Welcome(commands.Cog):
bg_used = "background_template.png" if not banner_path else (banner_path.split('/')[-1])
await interaction.response.send_message(f"✅ Configuration de bienvenida enregistrée !\nFond utilisé: {bg_used}", ephemeral=True)
@commands.slash_command(name="invitations", description="Affiche vos statistiques d'invitations")
async def invitations_stats(self, interaction: disnake.ApplicationCommandInteraction, user: disnake.Member = None):
user = user or interaction.author
await self._send_invitation_stats(interaction, user)
@commands.user_command(name="Stats Invitations")
async def invitations_user(self, interaction: disnake.UserCommandInteraction, user: disnake.Member):
await self._send_invitation_stats(interaction, user)
async def _send_invitation_stats(self, interaction, user):
invites_cog = self.bot.get_cog("Invites")
if not invites_cog:
return await interaction.response.send_message("❌ Le module d'invitations est désactivé.", ephemeral=True)
total, current = invites_cog.get_invite_stats(interaction.guild.id, user.id)
components = [
disnake.ui.Container(
disnake.ui.Section(
f"Statistiques d'invitations - {user.display_name}",
accessory=disnake.ui.Thumbnail(user.display_avatar.url)
),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(f"➜ Total d'invitations : **{total}**"),
disnake.ui.TextDisplay(f"➜ Membres présents : **{current}**")
)
]
await interaction.response.send_message(components=components)
def setup(bot):
bot.add_cog(Welcome(bot))

View file

@ -1,177 +1,177 @@
[
{
"date": "2026-05-09T20:09:46.350028+00:00",
"date": "2026-05-15T21:49:37.114321+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T19:21:55.267612+00:00",
"date": "2026-05-15T21:44:14.212871+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T19:18:48.771118+00:00",
"date": "2026-05-15T21:42:45.960991+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T19:16:02.785385+00:00",
"date": "2026-05-15T21:40:28.754282+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T19:13:21.426871+00:00",
"date": "2026-05-15T21:39:55.113067+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T19:11:56.119101+00:00",
"date": "2026-05-15T21:37:43.517923+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T19:09:05.080600+00:00",
"date": "2026-05-15T21:36:48.733530+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T19:05:36.915747+00:00",
"date": "2026-05-15T21:33:22.503958+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T19:02:55.250923+00:00",
"date": "2026-05-15T21:31:58.777625+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T19:01:09.375743+00:00",
"date": "2026-05-15T21:28:24.375077+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T18:59:45.523047+00:00",
"date": "2026-05-15T21:27:00.933212+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T18:58:44.658779+00:00",
"date": "2026-05-15T21:24:34.075332+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T18:58:21.696179+00:00",
"date": "2026-05-15T21:23:28.940963+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T18:55:40.065119+00:00",
"date": "2026-05-15T21:22:50.796880+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T18:52:58.024946+00:00",
"date": "2026-05-15T21:21:47.335364+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T18:50:48.754069+00:00",
"date": "2026-05-15T21:20:45.322710+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T18:48:10.476580+00:00",
"date": "2026-05-15T21:19:02.759168+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T18:43:40.581660+00:00",
"date": "2026-05-15T21:16:56.391804+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T18:41:30.288131+00:00",
"date": "2026-05-15T20:50:21.727551+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"membersCount": 13,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-05-09T18:39:06.565794+00:00",
"date": "2026-05-13T16:47:42.624873+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,

View file

@ -0,0 +1,9 @@
import disnake
import disnake.ui
c = disnake.ui.Container()
print(f"Container attributes: {dir(c)}")
try:
print(f"Children: {c.children}")
except Exception as e:
print(f"No children attribute: {e}")

12
scratch/check_file.py Normal file
View file

@ -0,0 +1,12 @@
import os
import json
member_id = 1141309757370675262
path = f"data/member_logs/{member_id}.json"
print(f"Path: {path}")
print(f"Exists: {os.path.exists(path)}")
if os.path.exists(path):
print(f"Size: {os.path.getsize(path)}")
with open(path, 'r') as f:
content = f.read()
print(f"Content: {repr(content)}")

View file

@ -106,21 +106,42 @@ class AdvancedLogger:
def check_previous_roles(self, member_id: int) -> Optional[Dict]:
"""Check if member has previous role data when rejoining"""
# This one stays synchronous for now as it's called in on_member_join and needs immediate result
# but since it only reads ONE file, it's less problematic than writing to cumulative logs.
# Format update: it now needs to read NDJSON.
member_log_file = f"{self.member_logs_dir}/{member_id}.json"
if not os.path.exists(member_log_file):
return None
try:
logs = []
with open(member_log_file, 'r', encoding='utf-8') as f:
# Read line by line for NDJSON
logs = [json.loads(line) for line in f if line.strip()]
content = f.read().strip()
if not content:
return None
# Handle legacy JSON list format
if content.startswith('['):
try:
logs = json.loads(content)
except json.JSONDecodeError:
# Fallback: try to strip brackets and commas
content_clean = content.strip('[]').strip()
# This is getting complex, let's just try line-by-line fallback
pass
# If not a list or failed list parse, try NDJSON line-by-line
if not logs:
for line in content.splitlines():
line = line.strip()
if not line: continue
# Strip trailing/leading commas in case of semi-corrupt legacy conversion
line = line.strip(',')
try:
logs.append(json.loads(line))
except json.JSONDecodeError:
continue # Skip corrupt lines
# Find the most recent leave event
leave_events = [log for log in logs if log.get("event_type") == "member_leave"]
leave_events = [log for log in logs if isinstance(log, dict) and log.get("event_type") == "member_leave"]
if leave_events:
return leave_events[-1].get("roles", {})