feat(moderation): DM détaillé lors des sanctions + commande /sanctions + salon de contestation

- Ajout d'un DM formaté (action, modérateur, durée, raison, lien contestation, lien règlement)
- Envoi automatique du DM pour warn, timeout, kick, ban
- Nouvelle commande /sanctions pour voir l'historique d'un membre
- Nouveau champ appeal_channel_id pour le salon de contestation
- Mise à jour de /modconfig avec bouton de configuration du salon de contestation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lowei 2026-05-21 23:02:00 +02:00
parent 9546e13001
commit db4876e085

View file

@ -4,6 +4,7 @@ import sqlite3
import os
from datetime import datetime, timedelta
import re
from src.logger import kuby_logger
class ModReasonModal(disnake.ui.Modal):
def __init__(self, cog, action_type, member):
@ -99,6 +100,7 @@ class Moderation(commands.Cog):
guild_id INTEGER PRIMARY KEY,
log_channel_id INTEGER,
board_channel_id INTEGER,
appeal_channel_id INTEGER,
warn_limit_timeout INTEGER DEFAULT 3,
warn_limit_kick INTEGER DEFAULT 5,
warn_limit_ban INTEGER DEFAULT 10,
@ -106,6 +108,8 @@ class Moderation(commands.Cog):
)''')
try: cursor.execute("ALTER TABLE mod_config ADD COLUMN board_channel_id INTEGER")
except: pass
try: cursor.execute("ALTER TABLE mod_config ADD COLUMN appeal_channel_id INTEGER")
except: pass
conn.commit(); conn.close()
def parse_duration(self, duration_str: str) -> int:
@ -119,6 +123,60 @@ class Moderation(commands.Cog):
def _type_to_emoji(self, s_type: str) -> str:
return {"WARN": "⚠️", "TIMEOUT": "", "KICK": "👢", "BAN": "🔨"}.get(s_type, "🛡️")
def _type_to_action_name(self, s_type: str) -> str:
return {"WARN": "Avertissement", "TIMEOUT": "Timeout", "KICK": "Expulsion", "BAN": "Ban"}.get(s_type, "Sanction")
def _format_duration(self, seconds: int) -> str:
if seconds >= 86400:
return f"{seconds // 86400} jour(s)"
elif seconds >= 3600:
return f"{seconds // 3600} heure(s)"
elif seconds >= 60:
return f"{seconds // 60} minute(s)"
return f"{seconds} seconde(s)"
async def send_sanction_dm(self, user, s_type: str, reason: str, moderator, guild, duration: int = None) -> bool:
"""Envoie un DM détaillé à l'utilisateur sanctionné. Retourne True si envoyé."""
from commandes.modules_security.rules import load_settings as load_rules_settings
emoji = self._type_to_emoji(s_type)
action_name = self._type_to_action_name(s_type)
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
cursor.execute('SELECT appeal_channel_id FROM mod_config WHERE guild_id = ?', (guild.id,))
res = cursor.fetchone()
conn.close()
rules_settings = load_rules_settings(guild.id)
appeal_channel = guild.get_channel(res[0]) if res and res[0] else None
rules_channel = guild.get_channel(rules_settings.get("rules_channel_id")) if rules_settings.get("rules_channel_id") else None
embed = disnake.Embed(
title=f"{emoji} {action_name} sur **{guild.name}**",
color=disnake.Color.red() if s_type in ("BAN", "KICK") else disnake.Color.orange()
)
embed.set_thumbnail(url=guild.icon.url if guild.icon else None)
embed.add_field(name="📋 Action", value=action_name, inline=True)
embed.add_field(name="👤 Modérateur", value=f"**{moderator}**", inline=True)
if duration:
embed.add_field(name="⏱️ Durée", value=self._format_duration(duration), inline=True)
embed.add_field(name="📝 Raison", value=f"*{reason if reason else 'Aucune raison spécifiée'}*", inline=False)
if appeal_channel:
embed.add_field(name="🔗 Contester cette sanction", value=f"Vous pouvez contester cette mesure dans {appeal_channel.mention}.", inline=False)
if rules_channel:
embed.add_field(name="📜 Règlement", value=f"[Voir le règlement](https://discord.com/channels/{guild.id}/{rules_channel.id})", inline=False)
try:
await user.send(embed=embed)
return True
except disnake.Forbidden:
kuby_logger.warning(f"Impossible d'envoyer un DM à {user} (DMs bloqués)")
return False
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)
@ -203,7 +261,7 @@ class Moderation(commands.Cog):
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,))
cursor.execute('SELECT log_channel_id, board_channel_id, appeal_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
@ -212,11 +270,13 @@ class Moderation(commands.Cog):
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.TextDisplay(f"🔗 **Contestation :** <#{res[2]}>" if res[2] else "🔗 **Contestation :** Non défini"),
disnake.ui.TextDisplay(f"⚖️ **Paliers :** T:{res[3]} | K:{res[4]} | B:{res[5]}"),
disnake.ui.TextDisplay(f"⏱️ **Timeout auto :** {res[6]}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("🔗 Salon de contestation", accessory=disnake.ui.Button(label="Contester", style=disnake.ButtonStyle.primary, custom_id="modcfg_appeal")),
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"))
]
@ -229,6 +289,31 @@ class Moderation(commands.Cog):
else:
await interaction.response.send_message(components=components, ephemeral=True)
@commands.slash_command(name="sanctions", description="Voir l'historique des sanctions d'un membre")
@commands.has_permissions(moderate_members=True)
async def sanctions(self, interaction: disnake.ApplicationCommandInteraction, member: disnake.Member):
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
cursor.execute('SELECT type, reason, timestamp, moderator_id, duration FROM sanctions WHERE guild_id = ? AND user_id = ? ORDER BY timestamp DESC LIMIT 10', (interaction.guild_id, member.id))
rows = cursor.fetchall(); conn.close()
if not rows:
return await interaction.response.send_message(f"Aucune sanction trouvée pour {member.mention}.", ephemeral=True)
children = [
disnake.ui.Section(f"📋 Historique : {member.name}", accessory=disnake.ui.Thumbnail(member.display_avatar.url)),
disnake.ui.Separator(divider=True),
]
for t, r, ts, mod_id, dur in rows:
ts_int = int(datetime.fromisoformat(str(ts)).timestamp()) if not isinstance(ts, datetime) else int(ts.timestamp())
moderator = interaction.guild.get_member(mod_id)
mod_name = moderator.name if moderator else f"Modérateur #{mod_id}"
dur_str = f" ({self._format_duration(dur)})" if dur else ""
children.append(disnake.ui.TextDisplay(f"{self._type_to_emoji(t)} **{t}{dur_str}** par **{mod_name}** — {r or 'Sans raison'} (<t:{ts_int}:R>)"))
components = [disnake.ui.Container(*children)]
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):
@ -297,6 +382,18 @@ class Moderation(commands.Cog):
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]))
elif act == "appeal":
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("UPDATE mod_config SET appeal_channel_id = ? WHERE guild_id = ?", (c.id, i.guild_id))
conn.commit(); conn.close()
await i.response.send_message(f"✅ Salon de contestation 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("Sélectionnez le salon pour la contestation :", view=view, ephemeral=True)
async def warn(self, interaction, member, reason):
conn = sqlite3.connect(self.db_path); cursor = conn.cursor()
@ -311,8 +408,9 @@ class Moderation(commands.Cog):
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
dm_sent = await self.send_sanction_dm(member, "WARN", reason, interaction.user, interaction.guild)
if not dm_sent:
kuby_logger.warning(f"DM non envoyé à {member} (DMs peut-être bloqués)")
if cfg:
lt, lk, lb, dur = cfg
if count >= lb: await member.ban(reason=f"Auto: {count} warns")
@ -329,22 +427,25 @@ class Moderation(commands.Cog):
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))
await self.send_sanction_dm(member, "TIMEOUT", reason, interaction.user, interaction.guild, duration=secs)
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))
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()
await self.send_sanction_dm(member, "KICK", reason, interaction.user, interaction.guild)
await member.kick(reason=reason)
if not interaction.response.is_done(): await interaction.response.send_message(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, 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))
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()
await self.send_sanction_dm(user, "BAN", reason, interaction.user, interaction.guild)
await interaction.guild.ban(user, reason=reason)
if not interaction.response.is_done(): await interaction.response.send_message(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))