Merge branch 'dev' into 'main'

Dev

See merge request Omega_Kube/kuby!35
This commit is contained in:
Gameur 2026-06-18 17:42:00 +02:00
commit b35cda27a6
7 changed files with 1217 additions and 35 deletions

1
bot.py
View file

@ -130,6 +130,7 @@ class MyBot(commands.Bot):
"commandes.moderation", "commandes.moderation",
"commandes.invites", "commandes.invites",
"commandes.taches", "commandes.taches",
"commandes.rapports",
] ]
for extension in extensions: for extension in extensions:

View file

@ -121,12 +121,45 @@ class Goodbye(commands.Cog):
kuby_logger.error(f"[Goodbye] Police introuvable: {font_path}") kuby_logger.error(f"[Goodbye] Police introuvable: {font_path}")
raise FileNotFoundError(f"Police introuvable: {font_path}") raise FileNotFoundError(f"Police introuvable: {font_path}")
font_large = ImageFont.truetype(font_large_path, 70) # Adapter le texte pour éviter les débordements et afficher correctement le nom
font_small = ImageFont.truetype(font_small_path, 50) line1_text = "AU REVOIR"
line2_text = username.upper()
line3_text = (server_name or "OMEGA KUBE").upper()
# Dessiner uniquement le nom du serveur (comme pour welcome) text_area_left = 300
display_name = server_name if server_name else "OMEGA KUBE" text_area_right = background.width - 20
draw.text((325, 135), display_name, font=font_large, fill=(255,255,255)) text_area_width = text_area_right - text_area_left
text_area_center_x = text_area_left + text_area_width // 2
def fit_font(font_path, text, max_size, max_width):
size = max_size
while size > 10:
font = ImageFont.truetype(font_path, size)
bbox = draw.textbbox((0, 0), text, font=font)
w = bbox[2] - bbox[0]
if w <= max_width:
h = bbox[3] - bbox[1]
return font, w, h
size -= 2
font = ImageFont.truetype(font_path, 10)
bbox = draw.textbbox((0, 0), text, font=font)
return font, bbox[2] - bbox[0], bbox[3] - bbox[1]
font1, w1, h1 = fit_font(font_large_path, line1_text, 65, text_area_width)
font2, w2, h2 = fit_font(font_small_path, line2_text, 38, text_area_width)
font3, w3, h3 = fit_font(font_large_path, line3_text, 65, text_area_width)
spacing_1_2 = 12
spacing_2_3 = 12
total_height = h1 + spacing_1_2 + h2 + spacing_2_3 + h3
start_y = (background.height - total_height) // 2
y = start_y
draw.text((text_area_center_x - w1 // 2, y), line1_text, font=font1, fill=(255, 255, 255))
y += h1 + spacing_1_2
draw.text((text_area_center_x - w2 // 2, y), line2_text, font=font2, fill=(255, 255, 255))
y += h2 + spacing_2_3
draw.text((text_area_center_x - w3 // 2, y), line3_text, font=font3, fill=(255, 255, 255))
# Ajouter l'avatar # Ajouter l'avatar
avatar = Image.open(io.BytesIO(avatar_bytes)).convert("RGBA").resize((224, 224), Image.LANCZOS) avatar = Image.open(io.BytesIO(avatar_bytes)).convert("RGBA").resize((224, 224), Image.LANCZOS)
@ -188,9 +221,10 @@ class Goodbye(commands.Cog):
if img: if img:
embed = disnake.Embed( embed = disnake.Embed(
description=f"{member.mention} vient de nous quitter.\n\n{message_text}\n\n⌛ **Avait rejoint :** {joined_message}", description=f"{member.mention} vient de nous quitter.\n\n{message_text}",
color=0x7289DA color=0x9B59B6
) )
embed.set_footer(text=f"avait rejoint · {joined_message}")
embed.set_image(url="attachment://goodbye.png") embed.set_image(url="attachment://goodbye.png")
await channel.send(embed=embed, file=disnake.File(img, filename="goodbye.png")) await channel.send(embed=embed, file=disnake.File(img, filename="goodbye.png"))
kuby_logger.info(f"[Goodbye] Message de départ avec image envoyé pour {member.name}") kuby_logger.info(f"[Goodbye] Message de départ avec image envoyé pour {member.name}")
@ -198,9 +232,10 @@ class Goodbye(commands.Cog):
# Fallback : image non générée # Fallback : image non générée
kuby_logger.warning(f"[Goodbye] Image non générée pour {member.name}, envoi d'un message texte") kuby_logger.warning(f"[Goodbye] Image non générée pour {member.name}, envoi d'un message texte")
embed = disnake.Embed( embed = disnake.Embed(
description=f"{member.mention} vient de nous quitter.\n\n{message_text}\n\n⌛ **Avait rejoint :** {joined_message}", description=f"{member.mention} vient de nous quitter.\n\n{message_text}",
color=0x7289DA color=0x9B59B6
) )
embed.set_footer(text=f"avait rejoint · {joined_message}")
await channel.send(embed=embed) await channel.send(embed=embed)
except Exception as e: except Exception as e:

980
commandes/rapports.py Normal file
View file

@ -0,0 +1,980 @@
import os
import sqlite3
import logging
import asyncio
from datetime import datetime, timedelta
try:
from zoneinfo import ZoneInfo
except ImportError:
from backports.zoneinfo import ZoneInfo
import disnake
from disnake.ext import commands, tasks
kuby_logger = logging.getLogger("KubyBot")
PARIS_TZ = ZoneInfo("Europe/Paris")
class ReportsDB:
def __init__(self, db_path):
self.db_path = db_path
self._init_db()
def _init_db(self):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
# Table pour les rapports
cursor.execute('''
CREATE TABLE IF NOT EXISTS reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id INTEGER NOT NULL,
author_id INTEGER NOT NULL,
title TEXT NOT NULL,
subject TEXT NOT NULL,
content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'En attente',
created_at TEXT NOT NULL,
last_edited_at TEXT,
channel_id INTEGER NOT NULL,
role_id_auteur INTEGER
)
''')
# Table pour la configuration du module
cursor.execute('''
CREATE TABLE IF NOT EXISTS reports_config (
guild_id INTEGER PRIMARY KEY,
viewer_role_id INTEGER
)
''')
conn.commit()
conn.close()
def get_config(self, guild_id):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("SELECT viewer_role_id FROM reports_config WHERE guild_id = ?", (guild_id,))
row = cursor.fetchone()
conn.close()
return row[0] if row else None
def set_config(self, guild_id, viewer_role_id):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO reports_config (guild_id, viewer_role_id)
VALUES (?, ?)
ON CONFLICT(guild_id) DO UPDATE SET viewer_role_id = excluded.viewer_role_id
''', (guild_id, viewer_role_id))
conn.commit()
conn.close()
def add_report(self, guild_id, author_id, title, subject, content, channel_id, role_id_auteur):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
created_at = datetime.now(PARIS_TZ).isoformat()
cursor.execute('''
INSERT INTO reports (guild_id, author_id, title, subject, content, status, created_at, channel_id, role_id_auteur)
VALUES (?, ?, ?, ?, ?, 'En attente', ?, ?, ?)
''', (guild_id, author_id, title, subject, content, created_at, channel_id, role_id_auteur))
report_id = cursor.lastrowid
conn.commit()
conn.close()
return report_id
def get_report(self, report_id):
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT * FROM reports WHERE id = ?", (report_id,))
row = cursor.fetchone()
conn.close()
return dict(row) if row else None
def update_report_status(self, report_id, status):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
last_edited_at = datetime.now(PARIS_TZ).isoformat()
cursor.execute('''
UPDATE reports
SET status = ?, last_edited_at = ?
WHERE id = ?
''', (status, last_edited_at, report_id))
conn.commit()
conn.close()
def update_report_content(self, report_id, title, subject, content):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
last_edited_at = datetime.now(PARIS_TZ).isoformat()
cursor.execute('''
UPDATE reports
SET title = ?, subject = ?, content = ?, last_edited_at = ?
WHERE id = ?
''', (title, subject, content, last_edited_at, report_id))
conn.commit()
conn.close()
def delete_report(self, report_id):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("DELETE FROM reports WHERE id = ?", (report_id,))
conn.commit()
conn.close()
def get_reports(self, guild_id, author_id=None, subject=None, status=None, role_id=None):
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
query = "SELECT * FROM reports WHERE guild_id = ?"
params = [guild_id]
if author_id is not None:
query += " AND author_id = ?"
params.append(author_id)
if subject is not None:
query += " AND subject LIKE ?"
params.append(f"%{subject}%")
if status is not None:
query += " AND status = ?"
params.append(status)
if role_id is not None:
query += " AND role_id_auteur = ?"
params.append(role_id)
query += " ORDER BY id ASC"
cursor.execute(query, params)
rows = cursor.fetchall()
conn.close()
return [dict(r) for r in rows]
def get_distinct_subjects(self, guild_id):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("SELECT DISTINCT subject FROM reports WHERE guild_id = ? ORDER BY subject ASC", (guild_id,))
rows = cursor.fetchall()
conn.close()
return [row[0] for row in rows if row[0]]
def purge_manual(self, guild_id):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cutoff = (datetime.now(PARIS_TZ) - timedelta(days=7)).isoformat()
cursor.execute('''
DELETE FROM reports
WHERE guild_id = ? AND status != 'Classé' AND created_at < ?
''', (guild_id, cutoff))
deleted_count = cursor.rowcount
conn.commit()
conn.close()
return deleted_count
def purge_automatic(self):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cutoff = (datetime.now(PARIS_TZ) - timedelta(days=90)).isoformat()
cursor.execute('''
DELETE FROM reports
WHERE status != 'Classé' AND created_at < ?
''', (cutoff,))
deleted_count = cursor.rowcount
conn.commit()
conn.close()
return deleted_count
class ReportsConfigView(disnake.ui.View):
def __init__(self, cog, guild_id: int):
super().__init__(timeout=300)
self.cog = cog
self.guild_id = guild_id
@disnake.ui.role_select(placeholder="Modifier le rôle Lecteur (Viewer)", min_values=1, max_values=1, custom_id="reports_select_viewer_role")
async def select_viewer_role(self, select: disnake.ui.RoleSelect, interaction: disnake.MessageInteraction):
if not interaction.author.guild_permissions.administrator:
return await interaction.response.send_message("❌ Vous devez être administrateur pour configurer ce module.", ephemeral=True)
role = select.values[0]
self.cog.db.set_config(self.guild_id, role.id)
embed = interaction.message.embeds[0]
embed.set_field_at(
0,
name="👁️ Rôle Lecteur (Viewer)",
value=f"{role.mention}\n*(Les membres ayant ce rôle peuvent consulter tous les rapports du serveur)*",
inline=False
)
await interaction.response.edit_message(embed=embed, view=self)
await interaction.followup.send(f"✅ Rôle Lecteur configuré sur {role.mention}", ephemeral=True)
@disnake.ui.button(label="Purge Manuelle", style=disnake.ButtonStyle.red, emoji="🗑️", custom_id="reports_manual_purge_btn")
async def manual_purge(self, button: disnake.ui.Button, interaction: disnake.MessageInteraction):
if not interaction.author.guild_permissions.administrator:
return await interaction.response.send_message("❌ Vous devez être administrateur pour lancer la purge.", ephemeral=True)
await interaction.response.defer(ephemeral=True)
deleted_count = self.cog.db.purge_manual(self.guild_id)
await interaction.followup.send(f"✅ Purge terminée ! **{deleted_count}** rapports (créés il y a > 7 jours et non-classés) ont été définitivement supprimés.", ephemeral=True)
class ReportModal(disnake.ui.Modal):
def __init__(self, cog):
self.cog = cog
components = [
disnake.ui.TextInput(
label="Titre du rapport",
placeholder="Entrez le titre du rapport...",
custom_id="report_title",
required=True,
max_length=100
),
disnake.ui.TextInput(
label="Objet du rapport",
placeholder="Ex: Bug, Plainte, Suggestion...",
custom_id="report_subject",
required=True,
max_length=100
),
disnake.ui.TextInput(
label="Contenu",
style=disnake.TextInputStyle.paragraph,
placeholder="Décrivez en détail...",
custom_id="report_content",
required=True,
max_length=2000
)
]
super().__init__(title="Créer un Rapport", custom_id="report_modal", components=components)
async def callback(self, interaction: disnake.ModalInteraction):
title = interaction.text_values["report_title"].strip()
subject = interaction.text_values["report_subject"].strip()
content = interaction.text_values["report_content"].strip()
role_id_auteur = interaction.author.top_role.id if interaction.author.roles else None
report_id = self.cog.db.add_report(
guild_id=interaction.guild.id,
author_id=interaction.author.id,
title=title,
subject=subject,
content=content,
channel_id=interaction.channel.id,
role_id_auteur=role_id_auteur
)
await interaction.response.send_message(
f"✅ Votre rapport a été enregistré avec succès sous l'ID **#{report_id}**.",
ephemeral=True
)
class EditReportModal(disnake.ui.Modal):
def __init__(self, cog, report_id, browser_view):
self.cog = cog
self.report_id = report_id
self.browser_view = browser_view
report = self.cog.db.get_report(report_id)
components = [
disnake.ui.TextInput(
label="Modifier le titre",
value=report['title'],
custom_id="edit_title",
required=True,
max_length=100
),
disnake.ui.TextInput(
label="Modifier l'objet",
value=report['subject'],
custom_id="edit_subject",
required=True,
max_length=100
),
disnake.ui.TextInput(
label="Modifier le contenu",
style=disnake.TextInputStyle.paragraph,
value=report['content'],
custom_id="edit_content",
required=True,
max_length=2000
)
]
super().__init__(title=f"Modifier le Rapport #{report_id}", custom_id=f"edit_modal_{report_id}", components=components)
async def callback(self, interaction: disnake.ModalInteraction):
report = self.cog.db.get_report(self.report_id)
if not report:
return await interaction.response.send_message("❌ Ce rapport n'existe plus.", ephemeral=True)
if report['status'] in ['Traité', 'Classé']:
return await interaction.response.send_message("❌ Ce rapport a été traité ou classé et ne peut plus être modifié.", ephemeral=True)
title = interaction.text_values["edit_title"].strip()
subject = interaction.text_values["edit_subject"].strip()
content = interaction.text_values["edit_content"].strip()
self.cog.db.update_report_content(self.report_id, title, subject, content)
updated_report = self.cog.db.get_report(self.report_id)
for idx, r in enumerate(self.browser_view.reports):
if r['id'] == self.report_id:
self.browser_view.reports[idx] = updated_report
break
self.browser_view.update_components()
await interaction.response.defer(ephemeral=True)
await interaction.message.edit(embed=self.browser_view.get_embed(), view=self.browser_view)
await interaction.followup.send("✅ Votre rapport a été modifié avec succès.", ephemeral=True)
class ReportsFilterView(disnake.ui.View):
def __init__(self, cog, interaction):
super().__init__(timeout=300)
self.cog = cog
self.interaction = interaction
self.viewer_id = interaction.author.id
self.guild_id = interaction.guild.id
self.is_admin = interaction.author.guild_permissions.administrator
viewer_role_id = self.cog.db.get_config(self.guild_id)
self.is_viewer = viewer_role_id in [r.id for r in interaction.author.roles] if viewer_role_id else False
self.has_global_access = self.is_admin or self.is_viewer
self.selected_user = None
self.selected_role = None
self.selected_status = None
self.selected_subject = None
self.update_components()
def update_components(self):
self.clear_items()
# Row 0: Menu Status
status_options = [
disnake.SelectOption(label="Tous les statuts", emoji="", value="all", default=(self.selected_status is None)),
disnake.SelectOption(label="En attente", emoji="🟡", value="En attente", default=(self.selected_status == "En attente")),
disnake.SelectOption(label="Traité", emoji="🟢", value="Traité", default=(self.selected_status == "Traité")),
disnake.SelectOption(label="Classé", emoji="🔴", value="Classé", default=(self.selected_status == "Classé"))
]
status_select = disnake.ui.StringSelect(
placeholder="Filtrer par statut...",
options=status_options,
custom_id="reports_filter_status",
row=0
)
async def status_callback(inter: disnake.MessageInteraction):
val = inter.values[0]
self.selected_status = None if val == "all" else val
self.update_components()
await inter.response.edit_message(embed=self.get_filter_embed(), view=self)
status_select.callback = status_callback
self.add_item(status_select)
# Row 1: Menu Catégorie
subjects = self.cog.db.get_distinct_subjects(self.guild_id)
if subjects:
subject_options = [
disnake.SelectOption(label="Toutes les catégories", emoji="📁", value="all", default=(self.selected_subject is None))
]
for subj in subjects[:24]:
subject_options.append(
disnake.SelectOption(label=subj, value=subj, default=(self.selected_subject == subj))
)
subject_select = disnake.ui.StringSelect(
placeholder="Filtrer par catégorie...",
options=subject_options,
custom_id="reports_filter_subject",
row=1
)
async def subject_callback(inter: disnake.MessageInteraction):
val = inter.values[0]
self.selected_subject = None if val == "all" else val
self.update_components()
await inter.response.edit_message(embed=self.get_filter_embed(), view=self)
subject_select.callback = subject_callback
self.add_item(subject_select)
current_row = 2
# Row 2 & 3: Filtres utilisateur / rôle (globaux uniquement)
if self.has_global_access:
user_select = disnake.ui.UserSelect(
placeholder="Filtrer par auteur...",
min_values=0,
max_values=1,
custom_id="reports_filter_user",
row=current_row
)
async def user_callback(inter: disnake.MessageInteraction):
if inter.values:
val = inter.values[0]
if isinstance(val, str):
self.selected_user = inter.guild.get_member(int(val)) or val
else:
self.selected_user = val
else:
self.selected_user = None
self.update_components()
await inter.response.edit_message(embed=self.get_filter_embed(), view=self)
user_select.callback = user_callback
self.add_item(user_select)
current_row += 1
role_select = disnake.ui.RoleSelect(
placeholder="Filtrer par rôle auteur...",
min_values=0,
max_values=1,
custom_id="reports_filter_role",
row=current_row
)
async def role_callback(inter: disnake.MessageInteraction):
if inter.values:
val = inter.values[0]
if isinstance(val, str):
self.selected_role = inter.guild.get_role(int(val)) or val
else:
self.selected_role = val
else:
self.selected_role = None
self.update_components()
await inter.response.edit_message(embed=self.get_filter_embed(), view=self)
role_select.callback = role_callback
self.add_item(role_select)
current_row += 1
# Row 4: Boutons Recherche / Reset
search_btn = disnake.ui.Button(
label="Rechercher",
style=disnake.ButtonStyle.green,
emoji="🔍",
custom_id="reports_filter_search",
row=current_row
)
async def search_callback(inter: disnake.MessageInteraction):
author_filter = None
if not self.has_global_access:
author_filter = self.viewer_id
elif self.selected_user:
author_filter = self.selected_user.id if hasattr(self.selected_user, "id") else int(self.selected_user)
role_filter_id = None
if self.selected_role:
role_filter_id = self.selected_role.id if hasattr(self.selected_role, "id") else int(self.selected_role)
reports = self.cog.db.get_reports(
guild_id=self.guild_id,
author_id=author_filter,
subject=self.selected_subject,
status=self.selected_status,
role_id=role_filter_id
)
if not reports:
await inter.response.send_message("❌ Aucun rapport ne correspond à votre recherche.", ephemeral=True)
return
browser = ReportsBrowserView(self.cog, self.interaction, reports, filter_view=self)
await inter.response.edit_message(embed=browser.get_embed(), view=browser)
search_btn.callback = search_callback
self.add_item(search_btn)
reset_btn = disnake.ui.Button(
label="Réinitialiser",
style=disnake.ButtonStyle.red,
emoji="",
custom_id="reports_filter_reset",
row=current_row
)
async def reset_callback(inter: disnake.MessageInteraction):
self.selected_user = None
self.selected_role = None
self.selected_status = None
self.selected_subject = None
self.update_components()
await inter.response.edit_message(embed=self.get_filter_embed(), view=self)
reset_btn.callback = reset_callback
self.add_item(reset_btn)
def get_filter_embed(self) -> disnake.Embed:
"""Génère l'embed résumant les filtres actifs."""
author = self.interaction.author
lines = []
lines.append(f"🚦 **Statut** : {self.selected_status or 'Tous'}")
if self.has_global_access:
if self.selected_user:
mention = self.selected_user.mention if hasattr(self.selected_user, 'mention') else f'<@{self.selected_user}>'
lines.append(f"👤 **Auteur** : {mention}")
else:
lines.append("👤 **Auteur** : Tous")
if self.selected_role:
mention = self.selected_role.mention if hasattr(self.selected_role, 'mention') else f'<@&{self.selected_role}>'
lines.append(f"🛡️ **Rôle Auteur** : {mention}")
else:
lines.append("🛡️ **Rôle Auteur** : Tous")
else:
lines.append(f"👤 **Auteur** : {author.mention} *(accès limité à vos rapports)*")
lines.append(f"📁 **Objet / Catégorie** : {self.selected_subject or 'Tous'}")
embed = disnake.Embed(
title="🔍 Recherche & Filtres de Rapports",
description="\n".join(lines),
color=disnake.Color.blurple()
)
embed.set_thumbnail(url=author.display_avatar.url)
return embed
def get_components_v2(self):
"""Kept for backward compatibility — returns view children only."""
return self.children
async def interaction_check(self, interaction: disnake.MessageInteraction) -> bool:
if interaction.author.id != self.viewer_id:
await interaction.response.send_message("❌ Vous ne pouvez pas interagir avec ce menu.", ephemeral=True)
return False
return True
class ReportsBrowserView(disnake.ui.View):
def __init__(self, cog, interaction, reports, filter_view=None, current_index=0):
super().__init__(timeout=600)
self.cog = cog
self.interaction = interaction
self.guild_id = interaction.guild.id
self.viewer_id = interaction.author.id
self.is_admin = interaction.author.guild_permissions.administrator
self.reports = reports
self.filter_view = filter_view
self.current_index = current_index
self.page_index = current_index // 25
self.update_components()
def update_components(self):
self.clear_items()
if not self.reports:
return
report = self.reports[self.current_index]
# Row 0: StringSelectMenu de selection du rapport
total_pages = (len(self.reports) + 24) // 25
start = self.page_index * 25
end = min(start + 25, len(self.reports))
options = []
for i in range(start, end):
rep = self.reports[i]
title = rep['title']
if len(title) > 60:
title = title[:57] + "..."
label = f"#{rep['id']} - {title}"
description = f"Statut: {rep['status']} | Objet: {rep['subject']}"
if len(description) > 100:
description = description[:97] + "..."
options.append(
disnake.SelectOption(
label=label,
description=description,
value=str(i),
default=(i == self.current_index)
)
)
select_menu = disnake.ui.StringSelect(
placeholder="Sélectionnez un rapport à lire...",
options=options,
custom_id="reports_browser_select",
row=0
)
async def select_callback(inter: disnake.MessageInteraction):
self.current_index = int(inter.values[0])
self.page_index = self.current_index // 25
self.update_components()
await inter.response.edit_message(embed=self.get_embed(), view=self)
select_menu.callback = select_callback
self.add_item(select_menu)
# Row 1: Boutons de pagination de pages (si > 25 rapports)
if total_pages > 1:
prev_btn = disnake.ui.Button(
label="⬅️ Page précédente",
style=disnake.ButtonStyle.secondary,
disabled=(self.page_index == 0),
custom_id="reports_browser_prev_page",
row=1
)
next_btn = disnake.ui.Button(
label="➡️ Page suivante",
style=disnake.ButtonStyle.secondary,
disabled=(self.page_index >= total_pages - 1),
custom_id="reports_browser_next_page",
row=1
)
async def prev_callback(inter: disnake.MessageInteraction):
self.page_index -= 1
self.current_index = self.page_index * 25
self.update_components()
await inter.response.edit_message(embed=self.get_embed(), view=self)
async def next_callback(inter: disnake.MessageInteraction):
self.page_index += 1
self.current_index = self.page_index * 25
self.update_components()
await inter.response.edit_message(embed=self.get_embed(), view=self)
prev_btn.callback = prev_callback
next_btn.callback = next_callback
self.add_item(prev_btn)
self.add_item(next_btn)
# Row 2: Boutons d'Action (Modifier, Supprimer, Retour)
is_author = (self.viewer_id == report['author_id'])
is_blocked = report['status'] in ['Traité', 'Classé']
edit_disabled = not is_author or is_blocked
delete_disabled = not (is_author or self.is_admin) or is_blocked
edit_btn = disnake.ui.Button(
label="Modifier",
style=disnake.ButtonStyle.primary,
emoji="✏️",
disabled=edit_disabled,
custom_id="reports_browser_edit",
row=2
)
delete_btn = disnake.ui.Button(
label="Supprimer",
style=disnake.ButtonStyle.danger,
emoji="🗑️",
disabled=delete_disabled,
custom_id="reports_browser_delete",
row=2
)
back_btn = disnake.ui.Button(
label="Retour aux filtres",
style=disnake.ButtonStyle.secondary,
emoji="⬅️",
custom_id="reports_browser_back",
row=2
)
async def edit_callback(inter: disnake.MessageInteraction):
report_fresh = self.cog.db.get_report(report['id'])
if not report_fresh:
return await inter.response.send_message("❌ Ce rapport n'existe plus.", ephemeral=True)
if report_fresh['status'] in ['Traité', 'Classé']:
return await inter.response.send_message("❌ Ce rapport a été traité ou classé et ne peut plus être modifié.", ephemeral=True)
if inter.author.id != report_fresh['author_id']:
return await inter.response.send_message("❌ Seul l'auteur du rapport peut le modifier.", ephemeral=True)
modal = EditReportModal(self.cog, report_fresh['id'], self)
await inter.response.send_modal(modal)
async def delete_callback(inter: disnake.MessageInteraction):
report_fresh = self.cog.db.get_report(report['id'])
if not report_fresh:
return await inter.response.send_message("❌ Ce rapport n'existe plus.", ephemeral=True)
if report_fresh['status'] in ['Traité', 'Classé']:
return await inter.response.send_message("❌ Ce rapport a été traité ou classé et ne peut pas être supprimé.", ephemeral=True)
is_auth = (inter.author.id == report_fresh['author_id'])
if not (is_auth or inter.author.guild_permissions.administrator):
return await inter.response.send_message("❌ Vous n'avez pas la permission de supprimer ce rapport.", ephemeral=True)
self.cog.db.delete_report(report['id'])
del self.reports[self.current_index]
if not self.reports:
if self.filter_view:
self.filter_view.update_components()
await inter.response.edit_message(
embed=self.filter_view.get_filter_embed(), view=self.filter_view
)
else:
await inter.response.edit_message(
content="✅ Le rapport a été supprimé. Plus aucun rapport dans cette recherche.",
embed=None, view=None
)
return
if self.current_index >= len(self.reports):
self.current_index = len(self.reports) - 1
self.page_index = self.current_index // 25
self.update_components()
await inter.response.edit_message(embed=self.get_embed(), view=self)
await inter.followup.send("✅ Rapport supprimé avec succès.", ephemeral=True)
async def back_callback(inter: disnake.MessageInteraction):
if self.filter_view:
self.filter_view.update_components()
await inter.response.edit_message(
embed=self.filter_view.get_filter_embed(), view=self.filter_view
)
else:
new_filter_view = ReportsFilterView(self.cog, self.interaction)
await inter.response.edit_message(
embed=new_filter_view.get_filter_embed(), view=new_filter_view
)
edit_btn.callback = edit_callback
delete_btn.callback = delete_callback
back_btn.callback = back_callback
self.add_item(edit_btn)
self.add_item(delete_btn)
self.add_item(back_btn)
# Row 3: Menu Déroulant "Changer l'état" (Admin uniquement)
if self.is_admin:
status_options = [
disnake.SelectOption(label="En attente", emoji="🟡", value="En attente"),
disnake.SelectOption(label="Traité", emoji="🟢", value="Traité"),
disnake.SelectOption(label="Classé", emoji="🔴", value="Classé")
]
status_menu = disnake.ui.StringSelect(
placeholder="Changer le statut...",
options=status_options,
custom_id="reports_browser_status",
row=3
)
async def status_callback(inter: disnake.MessageInteraction):
if not inter.author.guild_permissions.administrator:
return await inter.response.send_message("❌ Seuls les administrateurs peuvent modifier le statut.", ephemeral=True)
old_status = report['status']
new_status = inter.values[0]
if old_status == new_status:
return await inter.response.send_message(f" Le rapport est déjà en statut **{new_status}**.", ephemeral=True)
self.cog.db.update_report_status(report['id'], new_status)
updated_rep = self.cog.db.get_report(report['id'])
self.reports[self.current_index] = updated_rep
self.update_components()
await inter.response.edit_message(embed=self.get_embed(), view=self)
await inter.followup.send(
f"✅ Statut du rapport **#{report['id']}** mis à jour : **{old_status}** → **{new_status}**.",
ephemeral=True
)
# === Notification DM à l'auteur ===
status_colors = {
"En attente": disnake.Color.yellow(),
"Traité": disnake.Color.green(),
"Classé": disnake.Color.greyple(),
}
status_emojis = {"En attente": "🟡", "Traité": "🟢", "Classé": "🔴"}
dm_sent = False
try:
author_member = inter.guild.get_member(report['author_id'])
if not author_member:
author_member = await inter.guild.fetch_member(report['author_id'])
if author_member:
dm_embed = disnake.Embed(
title="📋 Mise à jour de votre rapport",
description=(
f"Le statut de votre rapport **#{report['id']}{report['title']}** "
f"vient d'être modifié."
),
color=status_colors.get(new_status, disnake.Color.blurple()),
timestamp=datetime.now(PARIS_TZ)
)
dm_embed.add_field(
name="Ancien statut",
value=f"{status_emojis.get(old_status, '')} {old_status}",
inline=True
)
dm_embed.add_field(
name="Nouveau statut",
value=f"{status_emojis.get(new_status, '')} {new_status}",
inline=True
)
dm_embed.add_field(name="Modifié par", value=inter.author.mention, inline=False)
dm_embed.add_field(name="Serveur", value=inter.guild.name, inline=False)
dm_embed.set_footer(text=f"Rapport #{report['id']}{inter.guild.name}")
if inter.guild.icon:
dm_embed.set_thumbnail(url=inter.guild.icon.url)
try:
await author_member.send(embed=dm_embed)
dm_sent = True
except disnake.Forbidden:
kuby_logger.info(
f"[Rapports] DM impossible pour {author_member} (DMs fermés) — rapport #{report['id']}"
)
except Exception as e:
kuby_logger.error(f"[Rapports] Erreur DM notification: {e}", exc_info=True)
except disnake.NotFound:
pass
except Exception as e:
kuby_logger.error(f"[Rapports] Erreur récupération membre pour DM: {e}", exc_info=True)
dm_note = " • 📬 Auteur notifié par DM." if dm_sent else " • ⚠️ DM non envoyé (DMs fermés ou membre introuvable)."
await inter.followup.send(
f"{dm_note}",
ephemeral=True
)
status_menu.callback = status_callback
self.add_item(status_menu)
def get_embed(self):
if not self.reports:
return disnake.Embed(
title="Aucun rapport disponible",
description="Il n'y a aucun rapport correspondant aux critères.",
color=disnake.Color.red()
)
report = self.reports[self.current_index]
status_colors = {
"En attente": 0xF1C40F,
"Traité": 0x2ECC71,
"Classé": 0x95A5A6
}
color = status_colors.get(report['status'], 0x3498DB)
status_emojis = {
"En attente": "🟡 En attente",
"Traité": "🟢 Traité",
"Classé": "🔴 Classé"
}
status_display = status_emojis.get(report['status'], report['status'])
embed = disnake.Embed(
title=f"Rapport #{report['id']} : {report['title']}",
description=report['content'],
color=color
)
embed.add_field(name="📁 Objet / Catégorie", value=report['subject'], inline=True)
embed.add_field(name="🚦 Statut", value=status_display, inline=True)
embed.add_field(name="👤 Auteur", value=f"<@{report['author_id']}>", inline=True)
try:
created_dt = datetime.fromisoformat(report['created_at'])
created_ts = int(created_dt.timestamp())
embed.add_field(name="📅 Date de création", value=f"<t:{created_ts}:F> (<t:{created_ts}:R>)", inline=False)
except Exception:
embed.add_field(name="📅 Date de création", value=report['created_at'], inline=False)
if report['last_edited_at']:
try:
edited_dt = datetime.fromisoformat(report['last_edited_at'])
edited_str = edited_dt.strftime("%d/%m/%Y à %H:%M")
embed.set_footer(text=f"Dernière action le {edited_str}")
except Exception:
embed.set_footer(text=f"Dernière action le {report['last_edited_at']}")
else:
embed.set_footer(text="Rapports Hybrides")
return embed
async def interaction_check(self, interaction: disnake.MessageInteraction) -> bool:
if interaction.author.id != self.viewer_id:
await interaction.response.send_message("❌ Vous ne pouvez pas interagir avec ce menu.", ephemeral=True)
return False
return True
class Rapports(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
self.db_path = os.path.join(self.base_dir, "config.db")
self.db = ReportsDB(self.db_path)
self.retention_loop.start()
def cog_unload(self):
self.retention_loop.cancel()
@tasks.loop(hours=24)
async def retention_loop(self):
try:
deleted_count = self.db.purge_automatic()
if deleted_count > 0:
kuby_logger.info(f"🧹 [Rétention] Nettoyage automatique effectué : {deleted_count} rapports de plus de 90 jours (non classés) supprimés.")
except Exception as e:
kuby_logger.error(f"❌ [Rétention] Erreur lors du nettoyage automatique: {e}", exc_info=True)
@retention_loop.before_loop
async def before_retention_loop(self):
await self.bot.wait_until_ready()
@commands.slash_command(name="config-system", description="Configuration du module de rapports (Admin uniquement)")
@commands.has_permissions(administrator=True)
async def config_system(self, interaction: disnake.ApplicationCommandInteraction, role_viewer: disnake.Role = None):
guild_id = interaction.guild.id
if role_viewer:
self.db.set_config(guild_id, role_viewer.id)
viewer_text = f"{role_viewer.mention}"
else:
viewer_role_id = self.db.get_config(guild_id)
viewer_text = f"<@&{viewer_role_id}>" if viewer_role_id else "Aucun rôle configuré"
embed = disnake.Embed(
title="⚙️ Configuration du Système de Rapports",
description="Gérez les autorisations de consultation globale et effectuez la maintenance.",
color=disnake.Color.blue()
)
embed.add_field(
name="👁️ Rôle Lecteur (Viewer)",
value=f"{viewer_text}\n*(Les membres ayant ce rôle peuvent consulter tous les rapports du serveur)*",
inline=False
)
embed.add_field(
name="🧹 Maintenance & Rétention",
value="Les rapports vieux de plus de **90 jours** sont automatiquement nettoyés chaque jour, sauf s'ils sont **Classés**.\n\nVous pouvez également lancer une purge manuelle pour supprimer les rapports de plus de **7 jours** non classés.",
inline=False
)
embed.set_footer(text="Système de Rapports Hybride")
view = ReportsConfigView(self, guild_id)
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
@commands.slash_command(name="rapport", description="Ouvrir le formulaire de saisie de rapport")
async def rapport(self, interaction: disnake.ApplicationCommandInteraction):
await interaction.response.send_modal(ReportModal(self))
@commands.slash_command(name="consulter_rapport", description="Consulter les rapports du serveur via une interface interactive")
async def consulter_rapport(
self,
interaction: disnake.ApplicationCommandInteraction,
utilisateur: disnake.Member = None,
categorie: str = None,
statut: str = commands.Param(None, choices=["En attente", "Traité", "Classé"]),
role: disnake.Role = None
):
filter_view = ReportsFilterView(self, interaction)
if utilisateur:
filter_view.selected_user = utilisateur
if categorie:
filter_view.selected_subject = categorie
if statut:
filter_view.selected_status = statut
if role:
filter_view.selected_role = role
filter_view.update_components()
await interaction.response.send_message(
embed=disnake.Embed(
title="🔍 Recherche & Filtres de Rapports",
description=(
f"🚦 **Statut** : {filter_view.selected_status or 'Tous'}\n"
f"📁 **Objet / Catégorie** : {filter_view.selected_subject or 'Tous'}\n"
+ (f"👤 **Auteur** : {filter_view.selected_user.mention if hasattr(filter_view.selected_user, 'mention') else 'Tous'}\n" if filter_view.has_global_access else f"👤 **Auteur** : {interaction.author.mention} *(accès limité à vos rapports)*\n")
+ (f"🛡️ **Rôle Auteur** : {filter_view.selected_role.mention if hasattr(filter_view.selected_role, 'mention') else 'Tous'}" if filter_view.has_global_access else "")
),
color=disnake.Color.blurple()
),
view=filter_view,
ephemeral=True
)
def setup(bot):
bot.add_cog(Rapports(bot))

View file

@ -1,24 +1,166 @@
import disnake import disnake
from disnake.ext import commands from disnake.ext import commands
OWNER_ID = 971446412690722826
def is_authorized(interaction: disnake.ApplicationCommandInteraction) -> bool:
return interaction.user.id == OWNER_ID
class AnnouncementModal(disnake.ui.Modal):
def __init__(self, cog: "SentBotMessages"):
self.cog = cog
components = [
disnake.ui.TextInput(
label="Titre principal de l'annonce",
placeholder="Ex: Mise à jour important / Maintenance / Nouveau système",
custom_id="announcement_title",
required=True,
max_length=100,
),
disnake.ui.TextInput(
label="Message de l'annonce (contenu réel)",
style=disnake.TextInputStyle.paragraph,
placeholder="Écris le vrai contenu de l'annonce ici.",
custom_id="announcement_message",
required=True,
max_length=4000,
),
disnake.ui.TextInput(
label="Message de prévention / contexte MP",
style=disnake.TextInputStyle.paragraph,
placeholder="Ex: Ce MP vous informe d'une annonce importante.",
custom_id="announcement_prevention",
required=True,
max_length=1000,
),
]
super().__init__(
title="Annonce aux propriétaires",
custom_id="announcement_modal",
components=components,
)
async def callback(self, interaction: disnake.ModalInteraction):
title = (interaction.text_values.get("announcement_title") or "").strip()
message = (interaction.text_values.get("announcement_message") or "").strip()
prevention = (interaction.text_values.get("announcement_prevention") or "").strip()
await interaction.response.defer(ephemeral=True)
preview_container = disnake.ui.Container(
disnake.ui.TextDisplay(content=f"# {title}"),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(content=prevention),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(content=message),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(
content=(
"📍 **Prévisualisation**\n"
"Le même format sera envoyé aux propriétaires."
)
)
)
successful = 0
failed_servers = []
for guild in self.cog.bot.guilds:
try:
owner = guild.owner
if owner is None:
owner = await guild.fetch_member(guild.owner_id)
container = disnake.ui.Container(
disnake.ui.TextDisplay(content=f"# {title}"),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(content=prevention),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(content=message),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(
content=(
f"📍 **Serveur** : {guild.name}\n"
f"🆔 **ID** : `{guild.id}`"
)
)
)
await owner.send(components=[container])
successful += 1
except Exception as e:
failed_servers.append(f"{guild.name} ({guild.id}) : {e}")
result_text = (
f"✅ Annonce envoyée à {successful} propriétaire(s)."
if not failed_servers
else (
f"✅ Annonce envoyée à {successful} propriétaire(s).\n"
f"❌ Échecs ({len(failed_servers)}):\n"
f"- {'\n- '.join(failed_servers[:10])}"
f"{'\n... et ' + str(len(failed_servers) - 10) + ' autres' if len(failed_servers) > 10 else ''}"
)
)
result_container = disnake.ui.Container(
disnake.ui.TextDisplay(content=result_text),
disnake.ui.Separator(divider=True),
disnake.ui.TextDisplay(content="🧩 Aperçu du format envoyé aux propriétaires :"),
disnake.ui.Separator(divider=True),
*preview_container.children
)
await interaction.edit_original_response(components=[result_container])
class SentBotMessages(commands.Cog): class SentBotMessages(commands.Cog):
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
@commands.slash_command(name="sentbotmessages", description="Le bot envoie un message dans un salon choisi.") @commands.slash_command(
async def sentbotmessages(self, interaction: disnake.ApplicationCommandInteraction, name="sentbotmessages",
description="Le bot envoie un message dans un salon choisi."
)
async def sentbotmessages(
self,
interaction: disnake.ApplicationCommandInteraction,
message: str = commands.Param(description="Le message à envoyer"), message: str = commands.Param(description="Le message à envoyer"),
salon: disnake.TextChannel = commands.Param(description="Le salon où envoyer le message")): salon: disnake.TextChannel = commands.Param(description="Le salon où envoyer le message")
):
# Vérifie si l'utilisateur est admin # Vérifie si l'utilisateur est admin
if not interaction.user.guild_permissions.administrator: if not interaction.user.guild_permissions.administrator:
await interaction.response.send_message("⛔ Tu n'as pas la permission d'utiliser cette commande.", ephemeral=True) await interaction.response.send_message(
"⛔ Tu n'as pas la permission d'utiliser cette commande.",
ephemeral=True
)
return return
try: try:
await salon.send(message) await salon.send(message)
await interaction.response.send_message(f"✅ Message envoyé dans {salon.mention}.", ephemeral=True) await interaction.response.send_message(
f"✅ Message envoyé dans {salon.mention}.",
ephemeral=True
)
except Exception as e: except Exception as e:
await interaction.response.send_message(f"❌ Une erreur est survenue : {e}", ephemeral=True) await interaction.response.send_message(
f"❌ Une erreur est survenue : {e}",
ephemeral=True
)
@commands.slash_command(
name="annonce_proprietaires",
description="Ouvrir le formulaire d'annonce pour les propriétaires."
)
@commands.check(is_authorized)
async def annonce_proprietaires(
self,
interaction: disnake.ApplicationCommandInteraction,
):
await interaction.response.send_modal(AnnouncementModal(self))
def setup(bot): def setup(bot):
bot.add_cog(SentBotMessages(bot)) bot.add_cog(SentBotMessages(bot))

View file

@ -3158,15 +3158,16 @@ class TicketCommands(commands.Cog):
if not custom_id: if not custom_id:
return return
# Accept both ticket_* / catv2_* / close_* custom_ids # Accept only ticket_* / catv2_* / cat_* / close_* custom_ids
if not ( # reports_filter_* are intentionally excluded — handled by ReportsFilterView callbacks
str(custom_id).startswith("ticket_") if not (str(custom_id).startswith("ticket_")
or str(custom_id).startswith("catv2_") or str(custom_id).startswith("catv2_")
or str(custom_id).startswith("cat_") or str(custom_id).startswith("cat_")
or str(custom_id) in {"close_confirm", "close_cancel", "delay_cancel"} or str(custom_id) in {"close_confirm", "close_cancel", "delay_cancel"}):
):
return return
if not inter.guild: if not inter.guild:
return return
@ -3219,6 +3220,7 @@ class TicketCommands(commands.Cog):
await inter.response.send_message(f"Erreur interne: {e}", ephemeral=True) await inter.response.send_message(f"Erreur interne: {e}", ephemeral=True)
# backward compatibility (legacy cat_<id>_<suffix>) # backward compatibility (legacy cat_<id>_<suffix>)
elif custom_id.startswith("cat_"): elif custom_id.startswith("cat_"):
try: try:
parts = str(custom_id).split("_") parts = str(custom_id).split("_")
@ -3399,13 +3401,11 @@ class TicketCommands(commands.Cog):
await self._send_recruitment_decision(inter, ticket, category, accepted=False) await self._send_recruitment_decision(inter, ticket, category, accepted=False)
# === Fermeture via composants V2 (sans view=, donc custom_id) === # === Fermeture via composants V2 (sans view=, donc custom_id) ===
elif custom_id == "close_cancel":
await inter.response.defer() elif custom_id.startswith("reports_filter_"):
# Retire la réponse éphémère du user (si c'est bien une réponse d'interaction) # No direct response; let the view's callback handle the interaction
try: return
await inter.delete_original_response()
except Exception:
pass
elif custom_id == "close_confirm": elif custom_id == "close_confirm":
try: try:

View file

@ -191,11 +191,27 @@ class Welcome(commands.Cog):
if welcome_dm_text and not member.bot: if welcome_dm_text and not member.bot:
kuby_logger.info(f"✉️ [Welcome] Tentative d'envoi de DM à {member.name}") kuby_logger.info(f"✉️ [Welcome] Tentative d'envoi de DM à {member.name}")
try: try:
formatted_message = welcome_dm_text.format( template_values = {
username=member.name, # Alias populaires
servername=member.guild.name, "user": member.mention,
member_count=member.guild.member_count "username": member.name,
) "member": member.mention,
"member_name": member.name,
"server": member.guild.name,
"servername": member.guild.name,
"guild": member.guild.name,
"guild_name": member.guild.name,
"member_count": member.guild.member_count,
"count": member.guild.member_count,
"members": member.guild.member_count,
}
class SafeDict(dict):
def __missing__(self, key):
return "{" + key + "}"
formatted_message = welcome_dm_text.format_map(SafeDict(template_values))
# On combine le texte et le GIF dans un Embed élégant pour masquer le lien brut # On combine le texte et le GIF dans un Embed élégant pour masquer le lien brut
gif_url = "https://i.giphy.com/media/v1.Y2lkPTc5MGI3NjExNXV1NW02NTk0bGF2ZHcyMGFna2F6amRrbGpobGpsMHowOW5xa2E5eCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/ZtFuW4rsUbfrRzPauJ/giphy.gif" gif_url = "https://i.giphy.com/media/v1.Y2lkPTc5MGI3NjExNXV1NW02NTk0bGF2ZHcyMGFna2F6amRrbGpobGpsMHowOW5xa2E5eCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/ZtFuW4rsUbfrRzPauJ/giphy.gif"
embed = disnake.Embed( embed = disnake.Embed(

View file

@ -1,5 +1,13 @@
{ {
"1369669999345537145_1337733859814543410": [ "1369669999345537145_1337733859814543410": [
1370452086193655840 1370452086193655840
] ],
"1369669999345537145_1508391404831903836": {
"roles": [],
"overrides": {
"1510558364420149310": {
"view_channel": false
}
}
}
} }