From ba9e297cb825018eb23a1cd1c815ae81b8a52be3 Mon Sep 17 00:00:00 2001 From: Mathis Date: Sat, 16 May 2026 18:05:04 +0200 Subject: [PATCH] migration vers new components V2, et ajout de diverse fonctions ! --- .gitignore | 3 +- TODO.md | 9 + bot.py | 122 ++++++++- commandes/bug_report.py | 97 ++++--- commandes/convocation.py | 134 +++++---- commandes/goodbye.py | 23 +- commandes/invites.py | 195 +++++++++++++ commandes/moderation.py | 441 ++++++++++++++++-------------- commandes/ticket/feedback_view.py | 2 +- commandes/welcome.py | 64 ++++- data/json-export-history.json | 78 +++--- scratch/check_container.py | 9 + scratch/check_file.py | 12 + src/advanced_logger.py | 33 ++- 14 files changed, 851 insertions(+), 371 deletions(-) create mode 100644 TODO.md create mode 100644 commandes/invites.py create mode 100644 scratch/check_container.py create mode 100644 scratch/check_file.py diff --git a/.gitignore b/.gitignore index 4af7f38..21803b9 100755 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,5 @@ logs/ # --- IDE & SYSTÈME --- .vscode/ .idea/ -.DS_Store \ No newline at end of file +.DS_Store +.roo/ \ No newline at end of file diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e8df080 --- /dev/null +++ b/TODO.md @@ -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 l’appeler dans `warn`, `timeout`, `kick`, `ban` (push automatique) +- [ ] Enrichir `/modpanel` pour afficher les sanctions actives récentes “proprement” (et garder l’UI 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 l’affichage dans le salon diff --git a/bot.py b/bot.py index 5e27395..b162d31 100755 --- a/bot.py +++ b/bot.py @@ -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: - try: - await member.add_roles(*roles_to_add) - kuby_logger.info(f"Restored {len(roles_to_add)} roles for {member}") - except Exception as e: - kuby_logger.error(f"Failed to restore roles: {e}") + # 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(*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"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}") diff --git a/commandes/bug_report.py b/commandes/bug_report.py index 0631984..ad344c0 100644 --- a/commandes/bug_report.py +++ b/commandes/bug_report.py @@ -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() - ) - embed.add_field(name="Nouveaux Labels / Statuts", value=current_labels_str) + # 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}") + ) + ] 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.") - 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}.") + 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() - ) - 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 !") + 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 !") + ) + ] 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: - 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).") + 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() - ) - embed.set_footer(text=f"Par {author_name}") + 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}") + ) + ] 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: - 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).") + 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: diff --git a/commandes/convocation.py b/commandes/convocation.py index 88253d7..d8bc255 100755 --- a/commandes/convocation.py +++ b/commandes/convocation.py @@ -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: - # 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 - - # 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) - 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 - except disnake.Forbidden: - pass # Skip if we can't touch this channel + 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)): + return False + + # Check existing override for the member + overwrites = channel.overwrites_for(membre) + if not overwrites.is_empty(): + # 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: + await channel.set_permissions(membre, view_channel=False, reason="Convocation Lockdown") + return True + except disnake.Forbidden: + 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,43 +202,59 @@ 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 - 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") + # 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 + role_ids = backup_data.get("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: + restored_roles.append(role) + + 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", {}) - 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 + + 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" - # 3. Restore roles - role_ids = backup_data.get("roles", []) - restored_roles = [] - if role_ids: - for rid in role_ids: - role = interaction.guild.get_role(rid) - if role and role.position < interaction.guild.me.top_role.position: - restored_roles.append(role) - - if restored_roles: - await membre.add_roles(*restored_roles, reason="Restauration fin de convocation") + 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) diff --git a/commandes/goodbye.py b/commandes/goodbye.py index ad3f87a..f906036 100644 --- a/commandes/goodbye.py +++ b/commandes/goodbye.py @@ -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 " - 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)) \ No newline at end of file diff --git a/commandes/invites.py b/commandes/invites.py new file mode 100644 index 0000000..cfe320b --- /dev/null +++ b/commandes/invites.py @@ -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)) diff --git a/commandes/moderation.py b/commandes/moderation.py index e250046..3569c52 100644 --- a/commandes/moderation.py +++ b/commandes/moderation.py @@ -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) + + async def callback(self, interaction: disnake.ModalInteraction): + reason = interaction.text_values["reason"] + member = self.member - cog = interaction.bot.get_cog("Moderation") - if not cog: - return await interaction.response.send_message("❌ Erreur interne.", ephemeral=True) - 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: \nCréé: ", 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** : ") + ] + + 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} ()")) + + 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)) diff --git a/commandes/ticket/feedback_view.py b/commandes/ticket/feedback_view.py index cedc0ac..7361091 100644 --- a/commandes/ticket/feedback_view.py +++ b/commandes/ticket/feedback_view.py @@ -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 diff --git a/commandes/welcome.py b/commandes/welcome.py index f3a4e70..ab515e6 100644 --- a/commandes/welcome.py +++ b/commandes/welcome.py @@ -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. " + ) if welcome_channel_message else "Bienvenue dans le serveur !\nSi vous cherchez des RolePlay de qualité,\nVous êtes au bon endroit. ") + 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)) diff --git a/data/json-export-history.json b/data/json-export-history.json index ce62dae..784e41f 100644 --- a/data/json-export-history.json +++ b/data/json-export-history.json @@ -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, diff --git a/scratch/check_container.py b/scratch/check_container.py new file mode 100644 index 0000000..36fd1c6 --- /dev/null +++ b/scratch/check_container.py @@ -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}") diff --git a/scratch/check_file.py b/scratch/check_file.py new file mode 100644 index 0000000..eb19763 --- /dev/null +++ b/scratch/check_file.py @@ -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)}") diff --git a/src/advanced_logger.py b/src/advanced_logger.py index 035e379..72b25dd 100755 --- a/src/advanced_logger.py +++ b/src/advanced_logger.py @@ -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", {})