From 5d9baee102529067d4ff7835bfec4b1d5515c5bc Mon Sep 17 00:00:00 2001 From: Mathis Date: Fri, 2 Oct 2026 19:45:46 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20full=20feature=20suite=20=E2=80=94=20we?= =?UTF-8?q?lcome,=20modmail,=20security,=20staff,=20avis,=20rules,=20links?= =?UTF-8?q?,=20shop,=20custom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit features/shared.py: per-guild JSON config store features/welcome.py: on_member_join message + invite tracking + /invites features/modmail.py: DM-based tickets with staff relay features/security.py: antiraid (join rate detection), ban protection, join/leave log features/staff.py: staff ranks, promote/demote, /staff_list features/avis.py: /avis modal -> embed in configured channel features/rules.py: /rules_setup with accept button + role assignment features/links.py: /links panel, /links_setup, /links_remove features/shop.py: /shop_announce new product embeds features/custom.py: /botstatus, /custom_send, /embed --- bot.py | 21 ++++++- features/__init__.py | 0 features/avis.py | 76 ++++++++++++++++++++++++ features/custom.py | 73 +++++++++++++++++++++++ features/links.py | 60 +++++++++++++++++++ features/modmail.py | 125 +++++++++++++++++++++++++++++++++++++++ features/rules.py | 91 +++++++++++++++++++++++++++++ features/security.py | 104 +++++++++++++++++++++++++++++++++ features/shared.py | 58 ++++++++++++++++++ features/shop.py | 62 ++++++++++++++++++++ features/staff.py | 107 ++++++++++++++++++++++++++++++++++ features/welcome.py | 136 +++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 911 insertions(+), 2 deletions(-) create mode 100644 features/__init__.py create mode 100644 features/avis.py create mode 100644 features/custom.py create mode 100644 features/links.py create mode 100644 features/modmail.py create mode 100644 features/rules.py create mode 100644 features/security.py create mode 100644 features/shared.py create mode 100644 features/shop.py create mode 100644 features/staff.py create mode 100644 features/welcome.py diff --git a/bot.py b/bot.py index a7e7b58..d3c3a2d 100644 --- a/bot.py +++ b/bot.py @@ -25,8 +25,25 @@ class VisionBot(commands.Bot): async def setup_hook(self): logger.info("Chargement des cogs...") - await self.load_extension("commandes.hello") - await self.load_extension("tickets.cog") + extensions = [ + "commandes.hello", + "tickets.cog", + "features.welcome", + "features.modmail", + "features.security", + "features.staff", + "features.avis", + "features.rules", + "features.links", + "features.shop", + "features.custom", + ] + for ext in extensions: + try: + await self.load_extension(ext) + logger.info(f" ✓ {ext}") + except Exception as e: + logger.error(f" ✗ {ext}: {e}") self.tree.on_error = self.on_app_command_error diff --git a/features/__init__.py b/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/features/avis.py b/features/avis.py new file mode 100644 index 0000000..eecef8d --- /dev/null +++ b/features/avis.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import logging + +import discord +from discord import app_commands +from discord.ext import commands + +from features import shared + +logger = logging.getLogger("vision") + + +class AvisModal(discord.ui.Modal, title="Donner mon avis"): + note = discord.ui.TextInput(label="Note (1-5)", max_length=1, placeholder="5") + commentaire = discord.ui.TextInput( + label="Commentaire", + style=discord.TextStyle.paragraph, + required=False, + max_length=1000, + ) + + def __init__(self, config: dict): + super().__init__() + self.config = config + + async def on_submit(self, interaction: discord.Interaction): + try: + note = max(1, min(5, int(self.note.value))) + except ValueError: + await interaction.response.send_message("Note invalide (1-5).", ephemeral=True) + return + + guild = interaction.guild or self.bot.get_guild(self.config["guild_id"]) + if not guild: + await interaction.response.send_message("Serveur introuvable.", ephemeral=True) + return + + channel = guild.get_channel(self.config["channel_id"]) + if not channel: + await interaction.response.send_message("Salon d'avis introuvable.", ephemeral=True) + return + + stars = "⭐" * note + embed = discord.Embed( + title="Avis", + description=f"**Note :** {stars} ({note}/5)\n**Auteur :** {interaction.user.mention}\n\n{self.commentaire.value or 'Aucun commentaire'}", + color=discord.Color.gold(), + ) + embed.set_thumbnail(url=interaction.user.display_avatar.url) + await channel.send(embed=embed) + await interaction.response.send_message("Merci pour ton avis !", ephemeral=True) + + +class Avis(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + @app_commands.command(name="avis_setup", description="Configurer le système d'avis") + @app_commands.describe(channel="Salon où envoyer les avis") + @app_commands.checks.has_permissions(manage_guild=True) + async def avis_setup(self, interaction: discord.Interaction, channel: discord.TextChannel): + shared.set(interaction.guild_id, "avis", {"channel_id": channel.id, "enabled": True}) + await interaction.response.send_message(f"Les avis seront envoyés dans {channel.mention}", ephemeral=True) + + @app_commands.command(name="avis", description="Donner un avis sur le serveur") + async def avis(self, interaction: discord.Interaction): + cfg = shared.section(interaction.guild_id, "avis") + if not cfg.get("enabled"): + await interaction.response.send_message("Le système d'avis n'est pas activé.", ephemeral=True) + return + await interaction.response.send_modal(AvisModal(cfg)) + + +async def setup(bot: commands.Bot): + await bot.add_cog(Avis(bot)) diff --git a/features/custom.py b/features/custom.py new file mode 100644 index 0000000..55c7310 --- /dev/null +++ b/features/custom.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import logging + +import discord +from discord import app_commands +from discord.ext import commands + +logger = logging.getLogger("vision") + + +class CustomMessages(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + @app_commands.command(name="botstatus", description="Changer le statut du bot") + @app_commands.describe(text="Texte du statut", type_type="Type: playing, watching, listening") + @app_commands.checks.has_permissions(manage_guild=True) + async def botstatus(self, interaction: discord.Interaction, text: str, type_type: str = "playing"): + activities = { + "playing": discord.ActivityType.playing, + "watching": discord.ActivityType.watching, + "listening": discord.ActivityType.listening, + "streaming": discord.ActivityType.streaming, + } + activity_type = activities.get(type_type.lower(), discord.ActivityType.playing) + activity = discord.Activity(type=activity_type, name=text) + await self.bot.change_presence(activity=activity) + await interaction.response.send_message(f"Statut changé : **{text}**", ephemeral=True) + + @app_commands.command(name="custom_send", description="Envoyer un message custom") + @app_commands.describe(channel="Canal", message="Contenu", title="Titre de l'embed") + @app_commands.checks.has_permissions(manage_guild=True) + async def custom_send( + self, interaction: discord.Interaction, channel: discord.TextChannel, message: str, title: str = "" + ): + if title: + embed = discord.Embed(title=title, description=message, color=discord.Color.blurple()) + await channel.send(embed=embed) + else: + await channel.send(message) + await interaction.response.send_message(f"Message envoyé dans {channel.mention}", ephemeral=True) + + @app_commands.command(name="embed", description="Envoyer un embed personnalisé") + @app_commands.describe( + channel="Canal", title="Titre", description="Description", color="Couleur (hex)", image_url="Image" + ) + @app_commands.checks.has_permissions(manage_guild=True) + async def embed( + self, + interaction: discord.Interaction, + channel: discord.TextChannel, + title: str, + description: str, + color: str = "#5865F2", + image_url: str = "", + ): + try: + color_int = int(color.lstrip("#"), 16) + except ValueError: + color_int = 0x5865F2 + + embed = discord.Embed(title=title, description=description, color=color_int) + if image_url: + embed.set_image(url=image_url) + embed.set_footer(text=f"Envoyé par {interaction.user}") + + await channel.send(embed=embed) + await interaction.response.send_message(f"Embed envoyé dans {channel.mention}", ephemeral=True) + + +async def setup(bot: commands.Bot): + await bot.add_cog(CustomMessages(bot)) diff --git a/features/links.py b/features/links.py new file mode 100644 index 0000000..a20eace --- /dev/null +++ b/features/links.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import logging + +import discord +from discord import app_commands +from discord.ext import commands + +from features import shared + +logger = logging.getLogger("vision") + +DEFAULT_LINKS = { + "Site web": "https://example.com", + "Discord": "https://discord.gg/example", + "YouTube": "https://youtube.com/@example", +} + + +class Links(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + @app_commands.command(name="links", description="Voir les liens du serveur") + async def links(self, interaction: discord.Interaction): + cfg = shared.section(interaction.guild_id, "links") + links = cfg.get("links", DEFAULT_LINKS) + + container = discord.ui.Container() + lines = [f"[{name}]({url})" for name, url in links.items()] + container.add_item(discord.ui.TextDisplay("## Liens du serveur\n" + "\n".join(lines))) + view = discord.ui.LayoutView() + view.add_item(container) + await interaction.response.send_message(view=view) + + @app_commands.command(name="links_setup", description="Configurer les liens") + @app_commands.describe(name="Nom du lien", url="URL du lien") + @app_commands.checks.has_permissions(manage_guild=True) + async def links_setup(self, interaction: discord.Interaction, name: str, url: str): + cfg = shared.get(interaction.guild_id, "links") + links = cfg.get("links", {}) + links[name] = url + cfg["links"] = links + shared.set(interaction.guild_id, "links", cfg) + await interaction.response.send_message(f"Lien **{name}** ajouté.", ephemeral=True) + + @app_commands.command(name="links_remove", description="Supprimer un lien") + @app_commands.describe(name="Nom du lien") + @app_commands.checks.has_permissions(manage_guild=True) + async def links_remove(self, interaction: discord.Interaction, name: str): + cfg = shared.get(interaction.guild_id, "links") + links = cfg.get("links", {}) + links.pop(name, None) + cfg["links"] = links + shared.set(interaction.guild_id, "links", cfg) + await interaction.response.send_message(f"Lien **{name}** supprimé.", ephemeral=True) + + +async def setup(bot: commands.Bot): + await bot.add_cog(Links(bot)) diff --git a/features/modmail.py b/features/modmail.py new file mode 100644 index 0000000..992f2fd --- /dev/null +++ b/features/modmail.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import contextlib +import logging + +import discord +from discord import app_commands +from discord.ext import commands + +from features import shared + +logger = logging.getLogger("vision") + +_open_tickets: dict[int, int] = {} + + +class ModMail(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + if message.author.bot or message.guild: + return + + cfg = shared.get(0, "modmail") + if not cfg.get("enabled"): + return + + guild_id = cfg.get("guild_id") + if not guild_id: + return + + guild = self.bot.get_guild(guild_id) + if not guild: + return + + existing = _open_tickets.get(message.author.id) + if existing: + channel = guild.get_channel(existing) + if channel: + embed = discord.Embed( + description=message.content or "*Pièce jointe*", + color=discord.Color.blurple(), + timestamp=message.created_at, + ) + embed.set_author(name=str(message.author), icon_url=message.author.display_avatar.url) + await channel.send(embed=embed) + for att in message.attachments: + await channel.send(file=await discord.File(await att.to_file(), filename=att.filename)) + return + + category_id = cfg.get("category_id") + staff_role_id = cfg.get("staff_role_id") + category = guild.get_channel(category_id) if category_id else None + + overwrites = { + guild.default_role: discord.PermissionOverwrite(view_channel=False), + guild.me: discord.PermissionOverwrite( + view_channel=True, + send_messages=True, + read_message_history=True, + attach_files=True, + manage_channels=True, + ), + message.author: discord.PermissionOverwrite( + view_channel=True, send_messages=True, read_message_history=True + ), + } + if staff_role_id: + role = guild.get_role(staff_role_id) + if role: + overwrites[role] = discord.PermissionOverwrite( + view_channel=True, send_messages=True, read_message_history=True, attach_files=True + ) + + channel = await guild.create_text_channel( + f"modmail-{message.author.name}", + category=category, + overwrites=overwrites, + topic=f"Modmail de {message.author} ({message.author.id})", + ) + + _open_tickets[message.author.id] = channel.id + + container = discord.ui.Container() + container.add_item( + discord.ui.TextDisplay( + f"## Modmail\n**De :** {message.author.mention}\n**Raison :** {message.content[:500] if message.content else 'Pièce jointe'}" + ) + ) + view = discord.ui.LayoutView() + view.add_item(container) + + mention = message.author.mention + if staff_role_id: + mention += f" <@&{staff_role_id}>" + await channel.send(content=mention, view=view) + + with contextlib.suppress(discord.HTTPException): + await message.author.send("Ton message a été envoyé au staff, réponses ici.") + + @app_commands.command(name="modmail_setup", description="Configurer le système de modmail") + @app_commands.describe(category="Category pour les tickets", staff_role="Rôle staff") + @app_commands.checks.has_permissions(manage_guild=True) + async def modmail_setup( + self, interaction: discord.Interaction, category: discord.CategoryChannel, staff_role: discord.Role + ): + shared.set( + 0, + "modmail", + { + "enabled": True, + "guild_id": interaction.guild_id, + "category_id": category.id, + "staff_role_id": staff_role.id, + }, + ) + await interaction.response.send_message( + f"Modmail configuré : catégorie {category.mention}, staff {staff_role.mention}", ephemeral=True + ) + + +async def setup(bot: commands.Bot): + await bot.add_cog(ModMail(bot)) diff --git a/features/rules.py b/features/rules.py new file mode 100644 index 0000000..5b9b4e5 --- /dev/null +++ b/features/rules.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import contextlib +import logging + +import discord +from discord import app_commands +from discord.ext import commands + +from features import shared + +logger = logging.getLogger("vision") + +DEFAULT_RULES = [ + "Respectez tous les membres du serveur.", + "Pas de spam ni de flood.", + "Pas de contenu NSFW ou gore.", + "Pas de publicité sans autorisation.", + "Utilisez les canaux appropriés.", + "Suivez les instructions du staff.", +] + + +class RulesAcceptButton(discord.ui.Button): + def __init__(self): + super().__init__( + label="J'accepte le règlement", + style=discord.ButtonStyle.success, + custom_id="rules_accept", + ) + + async def callback(self, interaction: discord.Interaction): + cfg = shared.section(interaction.guild_id, "rules") + role_id = cfg.get("role_id") + if role_id: + role = interaction.guild.get_role(role_id) + if role: + with contextlib.suppress(discord.HTTPException): + await interaction.user.add_roles(role, reason="Règlement accepté") + await interaction.response.send_message("Merci d'avoir accepté le règlement !", ephemeral=True) + + +class RulesView(discord.ui.LayoutView): + def __init__(self): + super().__init__(timeout=None) + container = discord.ui.Container() + container.add_item(discord.ui.ActionRow(RulesAcceptButton())) + self.add_item(container) + + +class Rules(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.bot.add_view(RulesView()) + + @app_commands.command(name="rules_setup", description="Configurer et envoyer le règlement") + @app_commands.describe(channel="Canal du règlement", role="Rôle donné après acceptation") + @app_commands.checks.has_permissions(manage_guild=True) + async def rules_setup(self, interaction: discord.Interaction, channel: discord.TextChannel, role: discord.Role): + cfg = shared.get(interaction.guild_id, "rules") + cfg["channel_id"] = channel.id + cfg["role_id"] = role.id + shared.set(interaction.guild_id, "rules", cfg) + + rules = cfg.get("rules", DEFAULT_RULES) + rules_text = "\n".join(f"{i + 1}. {r}" for i, r in enumerate(rules)) + + container = discord.ui.Container() + container.add_item(discord.ui.TextDisplay(f"## Règlement du serveur\n\n{rules_text}")) + container.add_item(discord.ui.Separator()) + container.add_item(discord.ui.ActionRow(RulesAcceptButton())) + + view = discord.ui.LayoutView() + view.add_item(container) + await channel.send(view=view) + + await interaction.response.send_message(f"Règlement publié dans {channel.mention}", ephemeral=True) + + @app_commands.command(name="rules_set", description="Modifier les règles") + @app_commands.describe(rule1="Règle 1", rule2="Règle 2", rule3="Règle 3") + @app_commands.checks.has_permissions(manage_guild=True) + async def rules_set(self, interaction: discord.Interaction, rule1: str, rule2: str = "", rule3: str = ""): + rules = [r for r in [rule1, rule2, rule3] if r] + cfg = shared.get(interaction.guild_id, "rules") + cfg["rules"] = rules + shared.set(interaction.guild_id, "rules", cfg) + await interaction.response.send_message(f"{len(rules)} règles mises à jour.", ephemeral=True) + + +async def setup(bot: commands.Bot): + await bot.add_cog(Rules(bot)) diff --git a/features/security.py b/features/security.py new file mode 100644 index 0000000..4d4570c --- /dev/null +++ b/features/security.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone + +import discord +from discord import app_commands +from discord.ext import commands + +from features import shared + +logger = logging.getLogger("vision") + +_raids: dict[int, list[tuple[datetime, str]]] = {} + + +class Security(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + cfg = shared.section(member.guild.id, "security") + if not cfg.get("antiraid"): + return + + now = datetime.now(timezone.utc) + raids = _raids.setdefault(member.guild.id, []) + raids.append((now, str(member.id))) + cutoff = now - timedelta(seconds=cfg.get("antiraid_window", 10)) + _raids[member.guild.id] = [(t, uid) for t, uid in raids if t > cutoff] + + threshold = cfg.get("antiraid_threshold", 5) + if len(_raids[member.guild.id]) >= threshold: + try: + await member.guild.edit(verification_level=discord.VerificationLevel.highest) + embed = discord.Embed( + title="🛡️ Alerte Antiraid", + description=f"**{len(_raids[member.guild.id])}** membres en {cfg.get('antiraid_window', 10)}s.\n" + f"**Verification_level** augmenté automatiquement.", + color=discord.Color.red(), + ) + if cfg.get("log_channel_id"): + ch = member.guild.get_channel(cfg["log_channel_id"]) + if ch: + await ch.send(embed=embed) + except discord.HTTPException: + pass + _raids[member.guild.id] = [] + + @commands.Cog.listener() + async def on_member_ban(self, guild: discord.Guild, user: discord.User | discord.Member): + cfg = shared.section(guild.id, "security") + if not cfg.get("ban_protection"): + return + embed = discord.Embed( + title="🔨 Protection Ban", + description=f"**{user}** a été banni de **{guild.name}**", + color=discord.Color.orange(), + ) + if cfg.get("log_channel_id"): + ch = guild.get_channel(cfg["log_channel_id"]) + if ch: + await ch.send(embed=embed) + + @commands.Cog.listener() + async def on_member_remove(self, member: discord.Member): + cfg = shared.section(member.guild.id, "security") + if not cfg.get("join_leave_log"): + return + embed = discord.Embed( + title="📥 Départ", + description=f"**{member}** a quitté **{member.guild.name}**", + color=discord.Color.grey(), + timestamp=datetime.now(timezone.utc), + ) + if cfg.get("log_channel_id"): + ch = member.guild.get_channel(cfg["log_channel_id"]) + if ch: + await ch.send(embed=embed) + + @app_commands.command(name="security_setup", description="Configurer la sécurité") + @app_commands.describe(log_channel="Canal de logs", antiraid_threshold="Seuil antiraid (membres/10s)") + @app_commands.checks.has_permissions(manage_guild=True) + async def security_setup( + self, interaction: discord.Interaction, log_channel: discord.TextChannel, antiraid_threshold: int = 5 + ): + cfg = shared.get(interaction.guild_id, "security") + cfg["enabled"] = True + cfg["log_channel_id"] = log_channel.id + cfg["antiraid"] = True + cfg["antiraid_threshold"] = antiraid_threshold + cfg["antiraid_window"] = 10 + cfg["ban_protection"] = True + cfg["join_leave_log"] = True + shared.set(interaction.guild_id, "security", cfg) + await interaction.response.send_message( + f"Sécurité activée. Logs dans {log_channel.mention}. Seuil antiraid : {antiraid_threshold} membres / 10s.", + ephemeral=True, + ) + + +async def setup(bot: commands.Bot): + await bot.add_cog(Security(bot)) diff --git a/features/shared.py b/features/shared.py new file mode 100644 index 0000000..485879b --- /dev/null +++ b/features/shared.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json +import threading +from pathlib import Path + +from config import DATA_DIR + +FEATURES_DIR = DATA_DIR / "features" +_LOCK = threading.Lock() +_cache: dict = {} + + +def _ensure(): + FEATURES_DIR.mkdir(parents=True, exist_ok=True) + + +def _path(guild_id: int) -> Path: + return FEATURES_DIR / f"{guild_id}.json" + + +def get(guild_id: int, key: str = "") -> dict: + with _LOCK: + _ensure() + if guild_id not in _cache: + f = _path(guild_id) + if f.exists(): + with open(f, "r", encoding="utf-8") as fh: + _cache[guild_id] = json.load(fh) + else: + _cache[guild_id] = {} + data = _cache[guild_id] + return data.get(key, {}) if key else data + + +def set(guild_id: int, key: str, value) -> None: + with _LOCK: + _ensure() + if guild_id not in _cache: + f = _path(guild_id) + if f.exists(): + with open(f, "r", encoding="utf-8") as fh: + _cache[guild_id] = json.load(fh) + else: + _cache[guild_id] = {} + _cache[guild_id][key] = value + _save(guild_id) + + +def _save(guild_id: int) -> None: + _ensure() + with open(_path(guild_id), "w", encoding="utf-8") as fh: + json.dump(_cache[guild_id], fh, indent=2, ensure_ascii=False) + + +def section(guild_id: int, name: str) -> dict: + data = get(guild_id) + return data.get(name, {}) diff --git a/features/shop.py b/features/shop.py new file mode 100644 index 0000000..ae60a33 --- /dev/null +++ b/features/shop.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import logging + +import discord +from discord import app_commands +from discord.ext import commands + +from features import shared + +logger = logging.getLogger("vision") + + +class Shop(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + @app_commands.command(name="shop_setup", description="Configurer les annonces boutique") + @app_commands.describe(channel="Canal des annonces boutique") + @app_commands.checks.has_permissions(manage_guild=True) + async def shop_setup(self, interaction: discord.Interaction, channel: discord.TextChannel): + shared.set(interaction.guild_id, "shop", {"channel_id": channel.id, "enabled": True}) + await interaction.response.send_message(f"Annonces boutique dans {channel.mention}", ephemeral=True) + + @app_commands.command(name="shop_announce", description="Annoncer un nouveau produit") + @app_commands.describe(name="Nom du produit", description="Description", price="Prix", image_url="URL de l'image") + @app_commands.checks.has_permissions(manage_guild=True) + async def shop_announce( + self, + interaction: discord.Interaction, + name: str, + description: str, + price: str = "", + image_url: str = "", + ): + cfg = shared.section(interaction.guild_id, "shop") + if not cfg.get("enabled"): + await interaction.response.send_message("Boutique non configurée.", ephemeral=True) + return + + channel = interaction.guild.get_channel(cfg["channel_id"]) + if not channel: + await interaction.response.send_message("Canal boutique introuvable.", ephemeral=True) + return + + embed = discord.Embed( + title=f"🆕 Nouveau produit — {name}", + description=description, + color=discord.Color.green(), + ) + if price: + embed.add_field(name="💰 Prix", value=price, inline=True) + if image_url: + embed.set_image(url=image_url) + + embed.set_footer(text=f"Annoncé par {interaction.user}") + await channel.send(embed=embed) + await interaction.response.send_message(f"Annonce envoyée dans {channel.mention}", ephemeral=True) + + +async def setup(bot: commands.Bot): + await bot.add_cog(Shop(bot)) diff --git a/features/staff.py b/features/staff.py new file mode 100644 index 0000000..f0aafd5 --- /dev/null +++ b/features/staff.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import logging + +import discord +from discord import app_commands +from discord.ext import commands + +from features import shared + +logger = logging.getLogger("vision") + +STAFF_RANKS = ["Membre", "Trial", "Modérateur", "Super Modérateur", "Admin", "Fondateur"] + + +class Staff(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + @app_commands.command(name="staff_setup", description="Configurer le système de staff") + @app_commands.describe(staff_role="Rôle principal du staff") + @app_commands.checks.has_permissions(manage_guild=True) + async def staff_setup(self, interaction: discord.Interaction, staff_role: discord.Role): + cfg = shared.get(interaction.guild_id, "staff") + cfg["enabled"] = True + cfg["staff_role_id"] = staff_role.id + cfg["ranks"] = { + "Membre": None, + "Trial": None, + "Modérateur": None, + "Super Modérateur": None, + "Admin": None, + "Fondateur": None, + } + shared.set(interaction.guild_id, "staff", cfg) + await interaction.response.send_message( + f"Système de staff configuré. Rôle principal : {staff_role.mention}", ephemeral=True + ) + + @app_commands.command(name="staff_promote", description="Promouvoir un membre du staff") + @app_commands.describe(member="Membre", rank="Rang") + @app_commands.checks.has_permissions(manage_roles=True) + async def staff_promote(self, interaction: discord.Interaction, member: discord.Member, rank: str): + cfg = shared.section(interaction.guild_id, "staff") + ranks = cfg.get("ranks", {}) + if rank not in ranks: + await interaction.response.send_message(f"Rang inconnu. Rangs : {', '.join(STAFF_RANKS)}", ephemeral=True) + return + + if rank not in [r for r in STAFF_RANKS]: + await interaction.response.send_message("Rang invalide.", ephemeral=True) + return + + staff = cfg.get("staff", []) + entry = next((s for s in staff if s["user_id"] == member.id), None) + if entry: + entry["rank"] = rank + else: + staff.append({"user_id": member.id, "username": str(member), "rank": rank}) + cfg["staff"] = staff + shared.set(interaction.guild_id, "staff", cfg) + + await interaction.response.send_message(f"**{member}** est maintenant **{rank}**.", ephemeral=True) + + @app_commands.command(name="staff_demote", description="Rétrograder un membre du staff") + @app_commands.describe(member="Membre") + @app_commands.checks.has_permissions(manage_roles=True) + async def staff_demote(self, interaction: discord.Interaction, member: discord.Member): + cfg = shared.get(interaction.guild_id, "staff") + staff = cfg.get("staff", []) + idx = next((i for i, s in enumerate(staff) if s["user_id"] == member.id), None) + if idx is None: + await interaction.response.send_message(f"**{member}** n'est pas dans le staff.", ephemeral=True) + return + + current = staff[idx]["rank"] + rank_idx = STAFF_RANKS.index(current) if current in STAFF_RANKS else 0 + new_rank = STAFF_RANKS[rank_idx - 1] if rank_idx > 0 else "Membre" + staff[idx]["rank"] = new_rank + cfg["staff"] = staff + shared.set(interaction.guild_id, "staff", cfg) + await interaction.response.send_message(f"**{member}** est maintenant **{new_rank}**.", ephemeral=True) + + @app_commands.command(name="staff_list", description="Voir la liste du staff") + async def staff_list(self, interaction: discord.Interaction): + cfg = shared.section(interaction.guild_id, "staff") + staff = cfg.get("staff", []) + if not staff: + await interaction.response.send_message("Aucun membre du staff.", ephemeral=True) + return + + lines = [] + for s in sorted( + staff, key=lambda x: STAFF_RANKS.index(x["rank"]) if x["rank"] in STAFF_RANKS else 0, reverse=True + ): + lines.append(f"**{s['rank']}** — <@{s['user_id']}>") + + embed = discord.Embed( + title="Équipe Staff", + description="\n".join(lines), + color=discord.Color.blurple(), + ) + await interaction.response.send_message(embed=embed) + + +async def setup(bot: commands.Bot): + await bot.add_cog(Staff(bot)) diff --git a/features/welcome.py b/features/welcome.py new file mode 100644 index 0000000..76eaaa0 --- /dev/null +++ b/features/welcome.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import logging + +import discord +from discord import app_commands +from discord.ext import commands + +from features import shared + +logger = logging.getLogger("vision") + + +class Welcome(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self._invites: dict[int, dict[str, int]] = {} + + @commands.Cog.listener() + async def on_ready(self): + await self._cache_invites() + + @commands.Cog.listener() + async def on_guild_join(self, guild: discord.Guild): + await self._cache_invites_for(guild) + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + cfg = shared.section(member.guild.id, "welcome") + if not cfg.get("enabled"): + return + + channel_id = cfg.get("channel_id") + if not channel_id: + return + channel = member.guild.get_channel(channel_id) + if not channel: + return + + inviter = await self._find_inviter(member) + + text = cfg.get("message", "**{user}** a rejoint **{guild}** ! ({member_count} membres)") + text = text.replace("{user}", member.mention).replace("{guild}", member.guild.name) + text = text.replace("{member_count}", str(member.guild.member_count)) + text = text.replace("{username}", member.name) + + embed = discord.Embed(description=text, color=discord.Color.blurple()) + embed.set_thumbnail(url=member.display_avatar.url) + + if inviter: + inviter_data = cfg.get("invites", {}).get(str(inviter.id), 0) + embed.add_field(name="Invité par", value=f"{inviter.mention} ({inviter_data + 1} invites)") + + await channel.send(embed=embed) + + if cfg.get("dm_message"): + try: + dm_text = cfg["dm_message"].replace("{guild}", member.guild.name) + await member.send(dm_text) + except discord.HTTPException: + pass + + await self._update_invite_count(member.guild, inviter) + + async def _find_inviter(self, member: discord.Member) -> discord.Member | None: + try: + invites = await member.guild.invites() + except discord.HTTPException: + return None + + cached = self._invites.get(member.guild.id, {}) + for inv in invites: + if cached.get(inv.code, 0) < inv.uses: + self._invites.setdefault(member.guild.id, {})[inv.code] = inv.uses + return inv.inviter + + self._cache_invites_for(member.guild) + return None + + async def _update_invite_count(self, guild: discord.Guild, inviter: discord.Member | None): + if not inviter: + return + cfg = shared.get(guild.id, "welcome") + invites = cfg.get("invites", {}) + key = str(inviter.id) + invites[key] = invites.get(key, 0) + 1 + shared.set(guild.id, "welcome", {**cfg, "invites": invites}) + + async def _cache_invites(self): + for guild in self.bot.guilds: + await self._cache_invites_for(guild) + + async def _cache_invites_for(self, guild: discord.Guild): + try: + invites = await guild.invites() + self._invites[guild.id] = {inv.code: inv.uses for inv in invites} + except discord.HTTPException: + self._invites[guild.id] = {} + + @app_commands.command(name="welcome_setup", description="Configurer le message d'arrivée") + @app_commands.describe(channel="Canal d'arrivée", message="Message (utilise {user}, {guild}, {member_count})") + @app_commands.checks.has_permissions(manage_guild=True) + async def welcome_setup(self, interaction: discord.Interaction, channel: discord.TextChannel, message: str = ""): + cfg = shared.get(interaction.guild_id, "welcome") + cfg["enabled"] = True + cfg["channel_id"] = channel.id + if message: + cfg["message"] = message + shared.set(interaction.guild_id, "welcome", cfg) + await interaction.response.send_message(f"Message d'arrivée configuré dans {channel.mention}", ephemeral=True) + + @app_commands.command(name="welcome_disable", description="Désactiver le message d'arrivée") + @app_commands.checks.has_permissions(manage_guild=True) + async def welcome_disable(self, interaction: discord.Interaction): + cfg = shared.get(interaction.guild_id, "welcome") + cfg["enabled"] = False + shared.set(interaction.guild_id, "welcome", cfg) + await interaction.response.send_message("Message d'arrivée désactivé.", ephemeral=True) + + @app_commands.command(name="invites", description="Voir le nombre d'invitations d'un membre") + @app_commands.describe(member="Membre à vérifier") + async def invites(self, interaction: discord.Interaction, member: discord.Member): + cfg = shared.get(interaction.guild_id, "welcome") + invites = cfg.get("invites", {}) + count = invites.get(str(member.id), 0) + embed = discord.Embed( + title=f"Invitations de {member}", + description=f"**{count}** invitations", + color=discord.Color.blurple(), + ) + embed.set_thumbnail(url=member.display_avatar.url) + await interaction.response.send_message(embed=embed) + + +async def setup(bot: commands.Bot): + await bot.add_cog(Welcome(bot))