Compare commits

...

2 commits

Author SHA1 Message Date
3ea90d7078 fix(ci): target pavillon80.changeip.org:444 instead of 192.168.1.117
Some checks failed
deploy / deploy (push) Failing after 0s
2026-10-04 16:48:30 +02:00
5d9baee102 feat: full feature suite — welcome, modmail, security, staff, avis, rules, links, shop, custom
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
2026-10-02 19:45:46 +02:00
13 changed files with 925 additions and 16 deletions

View file

@ -3,7 +3,7 @@
#
# Declencheur : push sur `main`.
# Runner : forgejo-runner self-hosted (label omhk-deploy, sur .22).
# Cible : 192.168.1.117 -> conteneur Docker `vision-bot`
# Cible : pavillon80.changeip.org:444 -> conteneur Docker `vision-bot`
# (/home/discord/Bots/VisionApp, bind-mount .:/app).
#
# Pipeline : syntaxe -> rsync -> deploy -> health check -> rollback auto.
@ -42,7 +42,7 @@ jobs:
chmod 700 ~/.ssh
printf '%s\n' "$VISION_SSH_KEY" > ~/.ssh/id_vision
chmod 600 ~/.ssh/id_vision
ssh-keyscan -H 192.168.1.117 >> ~/.ssh/known_hosts 2>/dev/null
ssh-keyscan -H -p 444 pavillon80.changeip.org >> ~/.ssh/known_hosts 2>/dev/null
shell: bash
# ═══ TEST SYNTAXE (sur .22, rapide) ══════════════════════════════
@ -89,13 +89,13 @@ jobs:
--exclude '__pycache__/' --exclude 'venv/' --exclude '.venv/' \
--exclude '*.db' --exclude '*.db-journal' --exclude '*.db-wal' --exclude '*.db-shm' \
--exclude '.vscode/' --exclude '.idea/' \
-e "ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts" \
-e "ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts -p 444" \
. \
discord@192.168.1.117:/home/discord/Bots/VisionApp/
discord@pavillon80.changeip.org:/home/discord/Bots/VisionApp/
shell: bash
# ═══ DEPLOY ═══════════════════════════════════════════════════════
- name: Deploiement du conteneur (docker compose, cote .117)
- name: Deploiement du conteneur (docker compose, cote pavillon80)
run: |
set -euo pipefail
cd repo
@ -105,8 +105,8 @@ jobs:
if echo "$CHANGED" | grep -qE '(^|/)(requirements\.txt|Dockerfile)$'; then
REBUILD="true"
fi
ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts \
discord@192.168.1.117 \
ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts -p 444 \
discord@pavillon80.changeip.org \
"bash /home/discord/Bots/VisionApp/scripts/deploy_runner.sh main '${COMMIT}' '${REBUILD}'"
shell: bash
@ -118,7 +118,7 @@ jobs:
echo "Attente du demarrage du bot..."
for i in $(seq 1 10); do
sleep 3
STATUS=$(curl -s -m 5 http://192.168.1.117:1890/health 2>/dev/null || echo "")
STATUS=$(curl -s -m 5 http://pavillon80.changeip.org:1890/health 2>/dev/null || echo "")
if echo "$STATUS" | grep -q '"status":"ok"'; then
echo "Health check OK (tentative $i)"
exit 0
@ -147,11 +147,11 @@ jobs:
--exclude '__pycache__/' --exclude 'venv/' --exclude '.venv/' \
--exclude '*.db' --exclude '*.db-journal' --exclude '*.db-wal' --exclude '*.db-shm' \
--exclude '.vscode/' --exclude '.idea/' \
-e "ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts" \
-e "ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts -p 444" \
. \
discord@192.168.1.117:/home/discord/Bots/VisionApp/
ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts \
discord@192.168.1.117 \
discord@pavillon80.changeip.org:/home/discord/Bots/VisionApp/
ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts -p 444 \
discord@pavillon80.changeip.org \
"bash /home/discord/Bots/VisionApp/scripts/deploy_runner.sh main '${PREV_COMMIT}' false"
echo "ROLLBACK termine"
exit 1
@ -160,7 +160,7 @@ jobs:
- name: Verification finale
run: |
set -euo pipefail
ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts \
discord@192.168.1.117 \
ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts -p 444 \
discord@pavillon80.changeip.org \
"docker ps --filter name=vision-bot --format '{{.Names}} {{.Status}}' && curl -s -m 5 http://localhost:1890/health && echo"
shell: bash

21
bot.py
View file

@ -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

0
features/__init__.py Normal file
View file

76
features/avis.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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))