feat: full feature suite — welcome, modmail, security, staff, avis, rules, links, shop, custom
All checks were successful
ci / ci (push) Successful in 1m1s
All checks were successful
ci / ci (push) Successful in 1m1s
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
This commit is contained in:
parent
33b9303948
commit
5d9baee102
12 changed files with 911 additions and 2 deletions
21
bot.py
21
bot.py
|
|
@ -25,8 +25,25 @@ class VisionBot(commands.Bot):
|
||||||
|
|
||||||
async def setup_hook(self):
|
async def setup_hook(self):
|
||||||
logger.info("Chargement des cogs...")
|
logger.info("Chargement des cogs...")
|
||||||
await self.load_extension("commandes.hello")
|
extensions = [
|
||||||
await self.load_extension("tickets.cog")
|
"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
|
self.tree.on_error = self.on_app_command_error
|
||||||
|
|
||||||
|
|
|
||||||
0
features/__init__.py
Normal file
0
features/__init__.py
Normal file
76
features/avis.py
Normal file
76
features/avis.py
Normal file
|
|
@ -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))
|
||||||
73
features/custom.py
Normal file
73
features/custom.py
Normal file
|
|
@ -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))
|
||||||
60
features/links.py
Normal file
60
features/links.py
Normal file
|
|
@ -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))
|
||||||
125
features/modmail.py
Normal file
125
features/modmail.py
Normal file
|
|
@ -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))
|
||||||
91
features/rules.py
Normal file
91
features/rules.py
Normal file
|
|
@ -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))
|
||||||
104
features/security.py
Normal file
104
features/security.py
Normal file
|
|
@ -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))
|
||||||
58
features/shared.py
Normal file
58
features/shared.py
Normal file
|
|
@ -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, {})
|
||||||
62
features/shop.py
Normal file
62
features/shop.py
Normal file
|
|
@ -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))
|
||||||
107
features/staff.py
Normal file
107
features/staff.py
Normal file
|
|
@ -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))
|
||||||
136
features/welcome.py
Normal file
136
features/welcome.py
Normal file
|
|
@ -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))
|
||||||
Loading…
Add table
Add a link
Reference in a new issue