Author SHA1 Message Date
Mow910 14c2cf0144 Refactor voice channel naming and access mode messaging. Removed emoji updates in channel names to prevent rate limiting, and streamlined access mode confirmation messages. Added logging for potential errors during channel renaming. 2026-02-17 18:25:48 +01:00
Mow910 d24be973b8 Amélioration de la gestion des permissions dans les salons vocaux automatiques. Préservation des permissions existantes pour le rôle "everyone" lors de l'application des modes d'accès, et mise à jour des overwrites pour les membres en fonction des listes blanche et noire. Cela permet une gestion plus fine des accès selon le mode sélectionné. 2026-02-17 17:56:07 +01:00
Mow910 5e4af406ff Ajout de la gestion des messages dans les salons automatiques avec la fonction on_message_auto_rooms. Mise à jour des emojis pour les modes d'accès et amélioration de l'embed de configuration des salons vocaux, incluant des sections pour les listes blanche et noire, ainsi que des informations sur le propriétaire et les modes d'accès. Correction de messages d'erreur et ajustements divers pour une meilleure expérience utilisateur. 2026-02-17 17:42:51 +01:00
Mow910 4d8038cc77 Fix: Corriger l'endpoint url_for dans youtube.html 2026-02-17 15:05:05 +01:00
Mow910 651773e63d Ajout de la sélection de tags pour les posts dans les forums lors du transfert de messages. Mise à jour de la classe TransferReasonModal pour inclure les tags sélectionnés et ajout d'une nouvelle classe ForumTagSelect pour gérer la sélection des tags. Amélioration de l'interaction utilisateur avec des messages contextuels pour la sélection des tags. 2026-02-14 20:15:08 +01:00
Mow910 60b90edcb3 Ajout de la commande contextuelle "Déplacer le message" pour transférer des messages entre canaux, avec gestion des forums et des threads. Intégration d'un modal pour spécifier la raison du transfert et mise à jour de la synchronisation des commandes d'application. 2026-02-14 19:36:39 +01:00
Mow910 179876d2ce Raison pour le titre du post dans le forum 2026-02-14 18:32:32 +01:00
Mow910 830ce61796 sans com 2026-02-14 18:28:03 +01:00
Mow910 5709b1c0a3 Amélioration de la commande !transfert pour supporter les canaux textuels, threads et forums. Ajout de vérifications de compatibilité pour le canal de destination et mise à jour de la documentation des commandes. Gestion des transferts de messages avec création de posts dans les forums et envoi via webhooks pour les autres types de canaux. 2026-02-14 18:01:31 +01:00
Mow910 11348bda39 Ajout de la commande !transfert pour transférer des messages entre canaux tout en préservant l'identité de l'auteur. Mise à jour de la documentation des commandes pour inclure cette nouvelle fonctionnalité et ses alias (!transfer, !move). 2026-02-14 16:55:52 +01:00
Mow910 f7e85bac69 Merge pull request #3 from Mow910/fix/twitch-auth-asyncio
Fix: Retrait de asyncio.run() pour le constructeur Twitch
2026-02-12 23:49:21 +01:00
Mow910andCursor d1d4e3b5a5 Fix: Retrait de asyncio.run() pour le constructeur Twitch
Le constructeur de la classe Twitch n'est pas une coroutine et ne doit pas être appelé avec asyncio.run(). Cela causait une ValueError lors de la demande de token Twitch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 23:47:50 +01:00
Mow910andCursor 5aa52c8137 Merge branch 'twitchbot' into main
Intégration de toutes les fonctionnalités développées dans la branche twitchbot :
- Système de modération Twitch complet
- Filtre de liens intelligent
- Notifications d'événements Twitch
- Système Freeloot Discord
- Salons automatiques Discord
- Authentification utilisateur
- Gestion des utilisateurs
- Interfaces d'administration étendues

Conflits résolus en faveur des versions de twitchbot pour :
- templates/commandes.html
- templates/configurations.html
- templates/humeurs.html
- templates/index.html
- templates/live-alert.html
- templates/moderation.html
- templates/protondb.html
- templates/template.html

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 22:40:27 +01:00
Mow910 9bbfa1fade Merge pull request #42 from skylanix/front-tailwind
Supprime les fichiers CSS obsolètes et met à jour les templates HTML …
2026-02-01 08:59:03 +01:00
Mow910 920ddfa172 Supprime les fichiers CSS obsolètes et met à jour les templates HTML pour une interface utilisateur améliorée. Les modifications incluent la refonte des pages de configuration, d'alerte live, de modération, et d'aide Twitch, avec une mise en page responsive et des styles modernisés. Les anciennes feuilles de style MVP.css et style.css ont été supprimées au profit d'une intégration de Tailwind CSS. 2026-01-25 19:07:13 +01:00
9 changed files with 972 additions and 725 deletions
+42 -3
View File
@@ -7,7 +7,7 @@ from webapp import webapp
from database import db
from database.helpers import ConfigurationHelper
from database.models import Configuration, Humeur, Commande
from discord import Message, TextChannel, Member, VoiceChannel
from discord import Message, TextChannel, Member, VoiceChannel, app_commands
from discordbot.humblebundle import checkHumbleBundleAndNotify
from discordbot.freeloot import checkFreeLootAndNotify
from discordbot.moderation import (
@@ -21,18 +21,49 @@ from discordbot.moderation import (
handle_ban_list_command,
handle_staff_help_command,
handle_timeout_command,
handle_say_command
handle_say_command,
handle_transfer_command,
transfer_message_context_menu
)
from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
from discordbot.youtube import checkYouTubeVideos
from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms
from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms, on_message_auto_rooms
from protondb import searhProtonDb
class DiscordBot(discord.Client):
def __init__(self, *, intents: discord.Intents):
super().__init__(intents=intents)
self.tree = app_commands.CommandTree(self)
self.synced = False
async def setup_hook(self):
self.tree.add_command(transfer_message_context_menu)
logging.info("Commande contextuelle 'Déplacer le message' ajoutée au CommandTree")
async def on_ready(self):
logging.info(f'Connecté en tant que {self.user} (ID: {self.user.id})')
webapp.config["BOT_STATUS"]["discord_connected"] = True
webapp.config["BOT_STATUS"]["discord_guild_count"] = len(self.guilds)
if not self.synced:
try:
logging.info("Synchronisation des commandes d'application en cours...")
for guild in self.guilds:
try:
synced = await self.tree.sync(guild=guild)
logging.info(f"{len(synced)} commande(s) synchronisée(s) pour le serveur '{guild.name}' (ID: {guild.id})")
except Exception as e:
logging.error(f"❌ Erreur lors de la synchronisation pour {guild.name}: {e}")
synced_global = await self.tree.sync()
logging.info(f"{len(synced_global)} commande(s) synchronisée(s) globalement")
self.synced = True
logging.info("🎉 Synchronisation complète terminée - Les commandes sont maintenant disponibles !")
except Exception as e:
logging.error(f"❌ Erreur lors de la synchronisation des commandes: {e}")
for c in self.get_all_channels() :
logging.info(f'{c.id} {c.name}')
@@ -120,6 +151,10 @@ bot = DiscordBot(intents=intents)
async def on_message(message: Message):
if message.author == bot.user:
return
# Gestion des messages dans les auto rooms (avant le check des commandes !)
await on_message_auto_rooms(bot, message)
if not message.content.startswith('!'):
return
command_name = message.content.split()[0]
@@ -167,6 +202,10 @@ async def on_message(message: Message):
await handle_say_command(message, bot)
return
if command_name in ['!transfert', '!transfer', '!move']:
await handle_transfer_command(message, bot)
return
if command_name in ['!aide', '!help']:
await handle_staff_help_command(message, bot)
return
+293 -68
View File
@@ -16,13 +16,14 @@ _control_message_ids: dict[int, tuple[int, int]] = {}
REACTIONS = [
("🔓", "open", "Ouvert"),
("🔒", "closed", "Fermé"),
("🛡️", "private", "Privé"),
("🔐", "private", "Privé"),
("", "whitelist", "Liste blanche"),
("🚫", "blacklist", "Liste noire"),
("🧹", "purge", "Purge"),
("👑", "transfer", "Propriété"),
("🎤", "speak", "Micro"),
("📹", "stream", "Vidéo"),
("📊", "soundboards", "Soundboards"),
("📝", "status", "Statut"),
]
@@ -34,40 +35,85 @@ def _status_display(access_mode: str) -> str:
if access_mode == "closed":
return "🔒 Fermé"
if access_mode == "private":
return "🔒 Privé"
return "🔐 Privé"
return "🔓 Ouvert"
def _status_emoji(access_mode: str) -> str:
"""Emoji cadenas seul pour le nom du channel."""
if access_mode == "private":
return "🔐"
return "🔓" if access_mode == "open" else "🔒"
def _build_control_embed(owner: Member, voice_channel: discord.VoiceChannel, access_mode: str) -> discord.Embed:
"""Construit lembed de config avec infos du salon."""
def _build_control_embed(owner: Member, voice_channel: discord.VoiceChannel, access_mode: str, room: dict = None) -> discord.Embed:
"""Construit l'embed de config avec infos du salon."""
embed = discord.Embed(
title="Configuration du salon",
title="⚙️ Configuration du salon",
description=(
"Voici lespace de configuration de votre salon vocal. "
"Utilisez les réactions ci-dessous — seul le propriétaire peut réagir."
"Voici l'espace de configuration de votre salon vocal temporaire. "
"Les différentes options disponibles vous permettent de personnaliser les permissions de votre salon selon vos préférences."
),
color=discord.Color.blurple()
color=discord.Color.orange()
)
members_count = len(voice_channel.members)
user_limit = voice_channel.user_limit or 0
limit_text = f"{user_limit} max" if user_limit else "Illimitée"
members_text = f"{members_count} / {user_limit}" if user_limit else str(members_count)
bitrate_kbps = (voice_channel.bitrate or 0) // 1000
embed.add_field(name="Propriétaire", value=owner.mention, inline=True)
embed.add_field(name="Statut du salon", value=_status_display(access_mode), inline=True)
embed.add_field(name="Nom du salon", value=voice_channel.name, inline=True)
embed.add_field(name="Membres", value=members_text, inline=True)
embed.add_field(name="Limite", value=limit_text, inline=True)
embed.add_field(name="Bitrate", value=f"{bitrate_kbps} kbps", inline=True)
embed.add_field(name="Accès", value="🔓 Ouvert · 🔒 Fermé · 🛡️ Privé", inline=False)
embed.add_field(name="Listes", value="✅ Liste blanche · 🚫 Liste noire", inline=False)
embed.add_field(name="Actions", value="🧹 Purge · 👑 Propriété · 🎤 Micro · 📹 Vidéo · 📝 Statut", inline=False)
# Récupération des infos
whitelist = room.get("whitelist", set()) if room else set()
blacklist = room.get("blacklist", set()) if room else set()
whitelist_text = f"{len(whitelist)} membre(s)" if whitelist else "Aucun"
blacklist_text = f"{len(blacklist)} membre(s)" if blacklist else "Aucun"
# Section Propriétaire
embed.add_field(
name=f"👤 Propriétaire du salon : {owner.display_name}",
value="",
inline=False
)
# Section Modes d'accès
mode_open = "🔓 **Ouvert**\nLe salon sera ouvert à tous les membres, sauf ceux figurant sur la liste noire."
mode_closed = "🔒 **Fermé**\nLe salon sera visible de tous, mais seulement accessible à la liste blanche."
mode_private = "🔐 **Privé**\nLe salon ne sera visible et accessible qu'aux membres de la liste blanche."
embed.add_field(name=mode_open, value="", inline=True)
embed.add_field(name=mode_closed, value="", inline=True)
embed.add_field(name=mode_private, value="", inline=True)
# Section Listes
embed.add_field(
name="📝 **Liste blanche**",
value=f"Les membres présents dans cette liste pourront toujours rejoindre le salon.\n\n{whitelist_text}",
inline=True
)
embed.add_field(
name="🚫 **Liste noire**",
value=f"Les membres présents dans cette liste ne pourront jamais rejoindre le salon.\n\n{blacklist_text}",
inline=True
)
embed.add_field(name="\u200b", value="", inline=True) # Spacer
# Section Purge
embed.add_field(
name="🧹 **Purge**",
value="Déconnecter tous les membres du salon vocal à l'exception de ceux présents dans la liste blanche.",
inline=False
)
# Section Transfert
embed.add_field(
name="👑 **Transférer**",
value="Transférer la gestion du salon au membre de votre choix.",
inline=False
)
# Note importante
embed.add_field(
name="💡",
value="Les membres de la liste blanche ne sont pas impactés par les permissions refusées aux membres.",
inline=False
)
embed.set_footer(text="Réagissez avec les émojis ci-dessous pour configurer votre salon")
return embed
@@ -114,11 +160,26 @@ def _find_room_by_message(message_id: int) -> Optional[tuple[int, int, dict]]:
async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist: set, blacklist: set):
guild = channel.guild
everyone = guild.default_role
overwrites = {}
overwrites = dict(channel.overwrites) # Récupérer les overwrites existants
# Préserver les permissions existantes pour everyone (stream, speak, soundboards, etc.)
existing_everyone_ow = overwrites.get(everyone, discord.PermissionOverwrite())
everyone_ow = discord.PermissionOverwrite()
# Copier les permissions importantes qui ne doivent pas être écrasées
everyone_ow.stream = existing_everyone_ow.stream
everyone_ow.speak = existing_everyone_ow.speak
everyone_ow.use_soundboard = existing_everyone_ow.use_soundboard
if mode == "open":
everyone_ow.connect = True
everyone_ow.view_channel = True
# Retirer les overwrites des membres qui ne sont plus dans la blacklist
for target in list(overwrites.keys()):
if target != everyone and isinstance(target, discord.Member):
if target.id not in blacklist:
overwrites.pop(target, None)
# Ajouter les overwrites pour la blacklist
for uid in blacklist:
m = guild.get_member(uid)
if m:
@@ -126,6 +187,12 @@ async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist
elif mode == "closed":
everyone_ow.connect = False
everyone_ow.view_channel = True
# Retirer les overwrites des membres qui ne sont plus dans la whitelist
for target in list(overwrites.keys()):
if target != everyone and isinstance(target, discord.Member):
if target.id not in whitelist:
overwrites.pop(target, None)
# Ajouter les overwrites pour la whitelist
for uid in whitelist:
m = guild.get_member(uid)
if m:
@@ -133,10 +200,17 @@ async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist
elif mode == "private":
everyone_ow.connect = False
everyone_ow.view_channel = False
# Retirer les overwrites des membres qui ne sont plus dans la whitelist
for target in list(overwrites.keys()):
if target != everyone and isinstance(target, discord.Member):
if target.id not in whitelist:
overwrites.pop(target, None)
# Ajouter les overwrites pour la whitelist
for uid in whitelist:
m = guild.get_member(uid)
if m:
overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True)
overwrites[everyone] = everyone_ow
await channel.edit(overwrites=overwrites)
@@ -145,7 +219,7 @@ async def _handle_reaction_action(bot: discord.Client, guild_id: int, owner_id:
"""channel = salon vocal (partie texte / onglet Discussion)."""
room = _get_room(guild_id, owner_id)
if not room:
await channel.send("Ce salon nexiste plus.")
await channel.send("Ce salon n'existe plus.")
return
voice_channel = bot.get_channel(room["voice_channel_id"])
if not voice_channel or not isinstance(voice_channel, discord.VoiceChannel):
@@ -155,36 +229,27 @@ async def _handle_reaction_action(bot: discord.Client, guild_id: int, owner_id:
if action in ("open", "closed", "private"):
room["access_mode"] = action
await _apply_access_mode(voice_channel, action, room.get("whitelist", set()), room.get("blacklist", set()))
# Mettre à jour le cadenas dans le nom du channel
try:
base_name = voice_channel.name.rstrip(" 🔓🔒")
new_name = f"{base_name} {_status_emoji(action)}"
await voice_channel.edit(name=new_name)
except discord.HTTPException:
pass
await channel.send(f"Accès du salon défini sur **{action}**.")
# Mettre à jour uniquement le statut (cadenas) dans le message de config
control_message_id = room.get("control_message_id")
if control_message_id:
try:
msg = await channel.fetch_message(control_message_id)
if msg.embeds:
embed = msg.embeds[0].copy()
for i, f in enumerate(embed.fields):
if f.name == "Statut du salon":
embed.set_field_at(i, name="Statut du salon", value=_status_display(action), inline=f.inline)
break
else:
embed.add_field(name="Statut du salon", value=_status_display(action), inline=False)
await msg.edit(embed=embed)
except discord.HTTPException:
pass
await channel.send(f"✅ Accès du salon défini sur **{_status_display(action)}**.")
# Mettre à jour l'embed
await _update_control_panel(bot, guild_id, owner_id, channel)
elif action == "whitelist":
await channel.send("Liste blanche : mentionnez un membre pour lajouter/retirer.")
whitelist = room.get("whitelist", set())
whitelist_text = ", ".join([f"<@{uid}>" for uid in whitelist]) if whitelist else "Aucun membre"
await channel.send(
f"**📝 Liste blanche actuelle :** {whitelist_text}\n\n"
f"Mentionnez un membre pour l'ajouter ou le retirer de la liste blanche."
)
room["awaiting_whitelist"] = True
elif action == "blacklist":
await channel.send("Liste noire : mentionnez un membre pour lajouter/retirer.")
blacklist = room.get("blacklist", set())
blacklist_text = ", ".join([f"<@{uid}>" for uid in blacklist]) if blacklist else "Aucun membre"
await channel.send(
f"**🚫 Liste noire actuelle :** {blacklist_text}\n\n"
f"Mentionnez un membre pour l'ajouter ou le retirer de la liste noire."
)
room["awaiting_blacklist"] = True
elif action == "purge":
whitelist = room.get("whitelist", set())
@@ -197,30 +262,80 @@ async def _handle_reaction_action(bot: discord.Client, guild_id: int, owner_id:
kicked += 1
except discord.HTTPException:
pass
await channel.send(f"Purge effectuée : {kicked} membre(s) déconnecté(s).")
await channel.send(f"🧹 Purge effectuée : {kicked} membre(s) déconnecté(s).")
elif action == "transfer":
await channel.send("Transférer le salon : mentionnez le membre à qui donner la propriété.")
await channel.send(
f"**👑 Transfert de propriété**\n\n"
f"Mentionnez le membre à qui vous souhaitez transférer la gestion du salon."
)
room["awaiting_transfer"] = True
elif action in ("speak", "stream"):
elif action in ("speak", "stream", "soundboards"):
everyone = voice_channel.guild.default_role
overwrites = dict(voice_channel.overwrites)
ow = overwrites.get(everyone) or discord.PermissionOverwrite()
# BUG FIX : Par défaut, Discord autorise stream, speak et soundboards
# Si la permission n'est pas explicitement définie (None), on considère qu'elle est True
current = getattr(ow, action)
setattr(ow, action, not current if current is not None else False)
if current is None:
# Permission non définie = autorisée par défaut dans Discord
# On veut la désactiver lors du premier clic
setattr(ow, action, False)
new_value = False
else:
# Permission définie, on l'inverse
setattr(ow, action, not current)
new_value = not current
overwrites[everyone] = ow
await voice_channel.edit(overwrites=overwrites)
label = "Micro" if action == "speak" else "Vidéo"
await channel.send(f"{label} : {'autorisé' if getattr(ow, action) else 'désactivé'} pour tous.")
labels = {"speak": "Micro", "stream": "Vidéo/Partage d'écran", "soundboards": "Soundboards"}
label = labels.get(action, action.capitalize())
await channel.send(f"{label} : {'autorisé' if new_value else 'désactivé'} pour tous.")
elif action == "status":
status_text = _status_display(room.get("access_mode", "open"))
await channel.send(f"Statut du salon : {status_text}\nRépondez avec le nouveau nom du salon pour le modifier.")
current_status = voice_channel.status or "Aucun statut défini"
await channel.send(
f"**Statut actuel du salon :** {current_status}\n\n"
f"Pour modifier le statut du salon (le texte affiché en haut du salon vocal), "
f"répondez avec le nouveau statut (max 500 caractères).\n"
f"💡 Pour supprimer le statut, répondez avec `clear` ou `effacer`."
)
room["awaiting_status"] = True
async def send_control_panel(bot: discord.Client, guild_id: int, owner: Member, voice_channel: discord.VoiceChannel) -> Optional[int]:
"""Envoie le message de config avec réactions dans la partie texte du salon vocal (onglet Discussion). Seul le proprio peut réagir. Retourne lid du message."""
embed = _build_control_embed(owner, voice_channel, "open")
async def _update_control_panel(bot: discord.Client, guild_id: int, owner_id: int, channel):
"""Met à jour le panneau de contrôle avec les nouvelles informations."""
room = _get_room(guild_id, owner_id)
if not room:
return
control_message_id = room.get("control_message_id")
if not control_message_id:
return
voice_channel = bot.get_channel(room["voice_channel_id"])
if not voice_channel or not isinstance(voice_channel, discord.VoiceChannel):
return
owner = voice_channel.guild.get_member(owner_id)
if not owner:
return
try:
msg = await channel.fetch_message(control_message_id)
embed = _build_control_embed(owner, voice_channel, room.get("access_mode", "open"), room)
await msg.edit(embed=embed)
except discord.HTTPException:
pass
async def send_control_panel(bot: discord.Client, guild_id: int, owner: Member, voice_channel: discord.VoiceChannel, room: dict) -> Optional[int]:
"""Envoie le message de config avec réactions dans la partie texte du salon vocal (onglet Discussion). Seul le proprio peut réagir. Retourne l'id du message."""
embed = _build_control_embed(owner, voice_channel, "open", room)
try:
# Message dans la partie texte du vocal (onglet Discussion à droite)
@@ -229,7 +344,7 @@ async def send_control_panel(bot: discord.Client, guild_id: int, owner: Member,
await msg.add_reaction(emoji)
return msg.id
except discord.HTTPException as e:
logging.error(f"Impossible denvoyer le panneau Auto Room dans le vocal : {e}")
logging.error(f"Impossible d'envoyer le panneau Auto Room dans le vocal : {e}")
return None
@@ -245,8 +360,8 @@ async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member,
if after.channel and after.channel.id == trigger_channel_id:
category = after.channel.category
# Nom du salon avec statut (cadenas) à la création
channel_name = f"Salon de {member.display_name} {_status_emoji('open')}"
# Nom du salon (simple, sans emoji pour éviter le rate limiting)
channel_name = f"Salon de {member.display_name}"
try:
new_channel = await guild.create_voice_channel(
name=channel_name,
@@ -254,16 +369,22 @@ async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member,
reason="Auto room"
)
await member.move_to(new_channel)
control_message_id = await send_control_panel(bot, guild.id, member, new_channel)
_set_room(guild.id, member.id, {
# Créer la room data d'abord
room_data = {
"guild_id": guild.id,
"voice_channel_id": new_channel.id,
"control_message_id": control_message_id,
"control_message_id": None, # Sera mis à jour après
"owner_id": member.id,
"whitelist": set(),
"blacklist": set(),
"access_mode": "open",
})
}
control_message_id = await send_control_panel(bot, guild.id, member, new_channel, room_data)
room_data["control_message_id"] = control_message_id
_set_room(guild.id, member.id, room_data)
logging.info(f"Auto room créé : {new_channel.name} pour {member.display_name}")
except discord.HTTPException as e:
logging.error(f"Erreur création auto room : {e}")
@@ -287,6 +408,110 @@ async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member,
pass
async def on_message_auto_rooms(bot: discord.Client, message: discord.Message):
"""Gère les messages dans les salons vocaux pour les actions (statut, liste blanche/noire, etc.)."""
if message.author.bot:
return
if not ConfigurationHelper().getValue("auto_rooms_enable"):
return
# Vérifier si c'est dans un salon vocal (partie texte)
if not isinstance(message.channel, discord.VoiceChannel):
return
# Trouver si c'est une auto room
result = _find_room_by_channel(message.guild.id, message.channel.id)
if not result:
return
owner_id, room = result
# Seul le propriétaire peut interagir
if message.author.id != owner_id:
return
voice_channel = message.channel
# Gestion du statut de salon
if room.get("awaiting_status"):
room["awaiting_status"] = False
new_status = message.content.strip()
try:
if new_status.lower() in ("clear", "effacer", "supprimer", "delete"):
await voice_channel.edit(status=None)
await message.channel.send("✅ Le statut du salon a été supprimé.")
elif len(new_status) > 500:
await message.channel.send("❌ Le statut ne peut pas dépasser 500 caractères.")
room["awaiting_status"] = True # Réessayer
else:
await voice_channel.edit(status=new_status)
await message.channel.send(f"✅ Le statut du salon a été mis à jour : **{new_status}**")
except discord.HTTPException as e:
await message.channel.send(f"❌ Erreur lors de la modification du statut : {e}")
return
# Gestion de la liste blanche (si en attente)
if room.get("awaiting_whitelist"):
room["awaiting_whitelist"] = False
if message.mentions:
target = message.mentions[0]
whitelist = room.get("whitelist", set())
if target.id in whitelist:
whitelist.remove(target.id)
await message.channel.send(f"{target.mention} a été retiré de la liste blanche.")
else:
whitelist.add(target.id)
await message.channel.send(f"{target.mention} a été ajouté à la liste blanche.")
room["whitelist"] = whitelist
await _apply_access_mode(voice_channel, room.get("access_mode", "open"), whitelist, room.get("blacklist", set()))
await _update_control_panel(bot, message.guild.id, owner_id, message.channel)
return
# Gestion de la liste noire (si en attente)
if room.get("awaiting_blacklist"):
room["awaiting_blacklist"] = False
if message.mentions:
target = message.mentions[0]
blacklist = room.get("blacklist", set())
if target.id in blacklist:
blacklist.remove(target.id)
await message.channel.send(f"{target.mention} a été retiré de la liste noire.")
else:
blacklist.add(target.id)
await message.channel.send(f"{target.mention} a été ajouté à la liste noire.")
room["blacklist"] = blacklist
await _apply_access_mode(voice_channel, room.get("access_mode", "open"), room.get("whitelist", set()), blacklist)
await _update_control_panel(bot, message.guild.id, owner_id, message.channel)
return
# Gestion du transfert de propriété (si en attente)
if room.get("awaiting_transfer"):
room["awaiting_transfer"] = False
if message.mentions:
new_owner = message.mentions[0]
if new_owner.id == owner_id:
await message.channel.send("❌ Vous êtes déjà le propriétaire du salon.")
return
# Transférer la propriété
old_owner_id = owner_id
_del_room(message.guild.id, old_owner_id)
room["owner_id"] = new_owner.id
_set_room(message.guild.id, new_owner.id, room)
# Renommer le salon (optionnel, peut être commenté pour éviter rate limit)
try:
new_name = f"Salon de {new_owner.display_name}"
await voice_channel.edit(name=new_name)
except discord.HTTPException as e:
logging.warning(f"Impossible de renommer le salon lors du transfert : {e}")
await message.channel.send(f"✅ La propriété du salon a été transférée à {new_owner.mention}.")
await _update_control_panel(bot, message.guild.id, new_owner.id, message.channel)
return
async def on_raw_reaction_add_auto_rooms(bot: discord.Client, payload: discord.RawReactionActionEvent):
"""Seul le propriétaire peut réagir ; on retire la réaction des autres."""
if payload.user_id == bot.user.id:
+542 -2
View File
@@ -4,12 +4,14 @@ import time
import os
import re
import discord
import io
from datetime import datetime, timezone, timedelta
from zoneinfo import ZoneInfo
from database import db
from database.helpers import ConfigurationHelper
from database.models import ModerationEvent
from discord import Message
from discord import Message, TextChannel, ForumChannel, Thread, app_commands
from discord.ui import Modal, TextInput, View, Select, ChannelSelect
def _get_local_tz():
tz_name = os.environ.get('APP_TZ') or os.environ.get('TZ') or 'Europe/Paris'
@@ -1055,7 +1057,15 @@ async def handle_staff_help_command(message: Message, bot):
value=(
"• `!say #channel message`\n"
" Envoie un message en tant que bot\n"
" Ex: `!say #annonces Nouvelle fonctionnalité !`"
" Ex: `!say #annonces Nouvelle fonctionnalité !`\n\n"
"• `!transfert #canal message_id [raison]`\n"
" Transfère un message vers un autre canal\n"
" *Alias: !transfer, !move*\n"
" Ex: `!transfert #entraide 123456789012345678`\n"
" Ex: `!transfert #général https://discord.com/channels/.../...`\n"
" Le message sera envoyé comme si c'était l'auteur original\n"
" Supporte les canaux textuels, threads et forums (crée un post)\n"
" Pour les forums, la raison devient le titre du post"
),
inline=False
)
@@ -1405,3 +1415,533 @@ async def handle_say_command(message: Message, bot):
except Exception as e:
logging.error(f"Erreur lors de l'envoi du message: {e}")
async def handle_transfer_command(message: Message, bot):
if not has_staff_role(message.author.roles):
await send_access_denied(message.channel)
return
parts = message.content.split(maxsplit=3)
if len(parts) < 3:
embed = discord.Embed(
title="📋 Utilisation de la commande",
description="**Syntaxe :** `!transfert #canal message_id [raison]` ou `!transfert #canal lien_message [raison]`",
color=discord.Color.blue()
)
embed.add_field(
name="Exemples",
value=(
"• `!transfert #entraide 123456789012345678`\n"
"• `!transfert #general https://discord.com/channels/.../...`\n"
"• `!transfert #entraide 123456789012345678 Message posté dans le mauvais canal`"
),
inline=False
)
embed.add_field(
name="Aliases",
value="`!transfert`, `!transfer`, `!move`",
inline=False
)
embed.add_field(
name="💡 Astuce",
value="Faites un clic droit sur un message → Copier l'identifiant du message, ou copiez le lien du message",
inline=False
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
target_channel = None
if message.channel_mentions:
target_channel = message.channel_mentions[0]
else:
try:
channel_id = int(parts[1].strip('<#>'))
target_channel = bot.get_channel(channel_id)
except ValueError:
pass
if not target_channel:
embed = discord.Embed(
title="❌ Erreur",
description="Canal de destination invalide. Mentionnez un canal avec #canal.",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
if not isinstance(target_channel, (TextChannel, ForumChannel, Thread)):
embed = discord.Embed(
title="❌ Erreur",
description=f"Le transfert n'est pas supporté vers ce type de canal. Utilisez un canal textuel, un forum ou un thread.",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
message_id = None
source_channel = message.channel
if 'discord.com/channels/' in parts[2]:
try:
link_parts = parts[2].split('/')
source_channel_id = int(link_parts[-2])
message_id = int(link_parts[-1])
source_channel = bot.get_channel(source_channel_id)
if not source_channel:
embed = discord.Embed(
title="❌ Erreur",
description="Canal source introuvable.",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
except (ValueError, IndexError):
embed = discord.Embed(
title="❌ Erreur",
description="Lien de message invalide.",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
else:
try:
message_id = int(parts[2])
except ValueError:
embed = discord.Embed(
title="❌ Erreur",
description="ID de message invalide. Utilisez un ID numérique ou un lien Discord.",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
try:
original_message = await source_channel.fetch_message(message_id)
except discord.NotFound:
embed = discord.Embed(
title="❌ Erreur",
description="Message introuvable. Vérifiez l'ID ou le lien.",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
except discord.Forbidden:
embed = discord.Embed(
title="❌ Erreur",
description="Je n'ai pas la permission d'accéder à ce message.",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
reason = parts[3] if len(parts) > 3 else "Message posté dans le mauvais canal"
content = original_message.content
embeds = original_message.embeds
files_to_send = []
for attachment in original_message.attachments:
try:
file_data = await attachment.read()
files_to_send.append(discord.File(
fp=io.BytesIO(file_data),
filename=attachment.filename
))
except Exception as e:
logging.error(f"Erreur lors du téléchargement de la pièce jointe: {e}")
transferred_message = None
if isinstance(target_channel, ForumChannel):
try:
post_title = f"{original_message.author.display_name} - "
if reason and reason != "Message posté dans le mauvais canal":
remaining_length = 100 - len(post_title)
post_title += reason[:remaining_length]
elif content and len(content) > 0:
remaining_length = 100 - len(post_title)
post_title += content[:remaining_length]
else:
post_title += "Message transféré"
if len(post_title) > 100:
post_title = post_title[:97] + "..."
transfer_notice = f"**Message original de {original_message.author.mention}**\n"
transfer_notice += f"*Ce message a été transféré par un membre du staff depuis {source_channel.mention}*\n"
transfer_notice += "" * 50 + "\n\n"
full_content = transfer_notice + (content or "")
thread = await target_channel.create_thread(
name=post_title,
content=full_content,
embeds=embeds[:10] if embeds else [],
files=files_to_send,
reason=f"Transfert depuis {source_channel.name} par {message.author.name}"
)
transferred_message = thread.message
except discord.HTTPException as e:
logging.error(f"Erreur lors de la création du post dans le forum: {e}")
embed = discord.Embed(
title="❌ Erreur",
description=f"Une erreur est survenue lors de la création du post dans le forum: {str(e)}",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
else:
webhooks = await target_channel.webhooks()
webhook = None
for wh in webhooks:
if wh.user == bot.user:
webhook = wh
break
if not webhook:
try:
webhook = await target_channel.create_webhook(name="Mamie Henriette - Transfert")
except discord.Forbidden:
embed = discord.Embed(
title="❌ Erreur",
description="Je n'ai pas la permission de créer un webhook dans le canal de destination.",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
try:
transferred_message = await webhook.send(
content=content,
username=original_message.author.display_name,
avatar_url=original_message.author.display_avatar.url,
embeds=embeds[:10] if embeds else [],
files=files_to_send,
allowed_mentions=discord.AllowedMentions.none(),
wait=True
)
except discord.HTTPException as e:
logging.error(f"Erreur lors du transfert du message: {e}")
embed = discord.Embed(
title="❌ Erreur",
description=f"Une erreur est survenue lors du transfert du message: {str(e)}",
color=discord.Color.red()
)
msg = await message.channel.send(embed=embed)
asyncio.create_task(delete_after_delay(msg))
return
try:
await original_message.delete()
except discord.Forbidden:
logging.warning(f"Impossible de supprimer le message original (ID: {message_id})")
transfer_details = f"De {source_channel.name} vers {target_channel.name}"
if isinstance(target_channel, ForumChannel):
transfer_details += " (forum)"
transfer_event = ModerationEvent(
type='transfer',
username=original_message.author.name,
discord_id=str(original_message.author.id),
created_at=datetime.now(timezone.utc),
reason=f"{reason} | {transfer_details}",
staff_id=str(message.author.id),
staff_name=message.author.name
)
db.session.add(transfer_event)
_commit_with_retry()
local_now = _to_local(datetime.now(timezone.utc))
destination_info = target_channel.mention if isinstance(target_channel, (TextChannel, Thread)) else f"le forum {target_channel.name}"
embed = discord.Embed(
title="✅ Message transféré",
description=f"Le message de **{original_message.author.name}** a été transféré vers {destination_info}",
color=discord.Color.green(),
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="👤 Auteur original", value=f"{original_message.author.mention}", inline=True)
embed.add_field(name="📤 Canal source", value=source_channel.mention, inline=True)
embed.add_field(name="📥 Canal destination", value=destination_info, inline=True)
embed.add_field(name="🛡️ Modérateur", value=message.author.mention, inline=True)
embed.add_field(name="📝 Raison", value=reason, inline=False)
if isinstance(target_channel, ForumChannel):
embed.add_field(name="️ Type", value="Nouveau post créé dans le forum", inline=False)
confirmation_msg = await source_channel.send(embed=embed)
asyncio.create_task(delete_after_delay(confirmation_msg))
log_embed = discord.Embed(
title="📨 Transfert de message",
description=f"Un message de **{original_message.author.name}** a été transféré",
color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc)
)
log_embed.add_field(name="👤 Auteur original", value=f"{original_message.author.name}\n`{original_message.author.id}`", inline=True)
log_embed.add_field(name="🛡️ Modérateur", value=f"**{message.author.name}**", inline=True)
log_embed.add_field(name="📅 Date et heure", value=local_now.strftime('%d/%m/%Y à %H:%M'), inline=True)
log_embed.add_field(name="📤 De", value=source_channel.mention, inline=True)
log_embed.add_field(name="📥 Vers", value=f"{target_channel.name} ({type(target_channel).__name__})", inline=True)
log_embed.add_field(name="📝 Raison", value=reason, inline=False)
preview = content[:100] + "..." if content and len(content) > 100 else content
if preview:
log_embed.add_field(name="💬 Aperçu du message", value=preview, inline=False)
log_embed.set_footer(text=f"ID Auteur: {original_message.author.id} • Serveur: {message.guild.name}")
await send_to_moderation_log_channel(bot, log_embed)
await safe_delete_message(message)
class TransferReasonModal(Modal, title="Raison du transfert"):
reason = TextInput(
label="Raison / Titre du post (forum)",
placeholder="Pour les forums, ceci devient le titre du post",
required=False,
max_length=200,
style=discord.TextStyle.paragraph
)
def __init__(self, message: discord.Message, bot, target_channel, selected_tags=None):
super().__init__()
self.message = message
self.bot = bot
self.target_channel = target_channel
self.selected_tags = selected_tags or []
async def on_submit(self, interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
reason = self.reason.value.strip() if self.reason.value else "Message posté dans le mauvais canal"
target_channel = self.target_channel
source_channel = self.message.channel
content = self.message.content
embeds = self.message.embeds
files_to_send = []
for attachment in self.message.attachments:
try:
file_data = await attachment.read()
files_to_send.append(discord.File(
fp=io.BytesIO(file_data),
filename=attachment.filename
))
except Exception as e:
logging.error(f"Erreur lors du téléchargement de la pièce jointe: {e}")
transferred_message = None
if isinstance(target_channel, ForumChannel):
try:
post_title = f"{self.message.author.display_name} - "
if reason and reason != "Message posté dans le mauvais canal":
remaining_length = 100 - len(post_title)
post_title += reason[:remaining_length]
elif content and len(content) > 0:
remaining_length = 100 - len(post_title)
post_title += content[:remaining_length]
else:
post_title += "Message transféré"
if len(post_title) > 100:
post_title = post_title[:97] + "..."
transfer_notice = f"**Message original de {self.message.author.mention}**\n"
transfer_notice += f"*Ce message a été transféré par un membre du staff depuis {source_channel.mention}*\n"
transfer_notice += "" * 50 + "\n\n"
full_content = transfer_notice + (content or "")
thread = await target_channel.create_thread(
name=post_title,
content=full_content,
embeds=embeds[:10] if embeds else [],
files=files_to_send,
applied_tags=self.selected_tags,
reason=f"Transfert depuis {source_channel.name} par {interaction.user.name}"
)
transferred_message = thread.message
if self.selected_tags:
tag_names = ", ".join([tag.name for tag in self.selected_tags])
logging.info(f"Post créé avec les tags: {tag_names}")
except discord.HTTPException as e:
logging.error(f"Erreur lors de la création du post dans le forum: {e}")
await interaction.followup.send(f"❌ Erreur lors de la création du post: {str(e)}", ephemeral=True)
return
else:
webhooks = await target_channel.webhooks()
webhook = None
for wh in webhooks:
if wh.user == self.bot.user:
webhook = wh
break
if not webhook:
try:
webhook = await target_channel.create_webhook(name="Mamie Henriette - Transfert")
except discord.Forbidden:
await interaction.followup.send("❌ Je n'ai pas la permission de créer un webhook dans le canal de destination.", ephemeral=True)
return
try:
transferred_message = await webhook.send(
content=content,
username=self.message.author.display_name,
avatar_url=self.message.author.display_avatar.url,
embeds=embeds[:10] if embeds else [],
files=files_to_send,
allowed_mentions=discord.AllowedMentions.none(),
wait=True
)
except discord.HTTPException as e:
logging.error(f"Erreur lors du transfert du message: {e}")
await interaction.followup.send(f"❌ Erreur lors du transfert: {str(e)}", ephemeral=True)
return
try:
await self.message.delete()
except discord.Forbidden:
logging.warning(f"Impossible de supprimer le message original (ID: {self.message.id})")
transfer_details = f"De {source_channel.name} vers {target_channel.name}"
if isinstance(target_channel, ForumChannel):
transfer_details += " (forum)"
transfer_event = ModerationEvent(
type='transfer',
username=self.message.author.name,
discord_id=str(self.message.author.id),
created_at=datetime.now(timezone.utc),
reason=f"{reason} | {transfer_details}",
staff_id=str(interaction.user.id),
staff_name=interaction.user.name
)
db.session.add(transfer_event)
_commit_with_retry()
destination_info = target_channel.mention if isinstance(target_channel, (TextChannel, Thread)) else f"le forum {target_channel.name}"
await interaction.followup.send(
f"✅ Message de **{self.message.author.name}** transféré vers {destination_info}",
ephemeral=True
)
local_now = _to_local(datetime.now(timezone.utc))
log_embed = discord.Embed(
title="📨 Transfert de message",
description=f"Un message de **{self.message.author.name}** a été transféré",
color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc)
)
log_embed.add_field(name="👤 Auteur original", value=f"{self.message.author.name}\n`{self.message.author.id}`", inline=True)
log_embed.add_field(name="🛡️ Modérateur", value=f"**{interaction.user.name}**", inline=True)
log_embed.add_field(name="📅 Date et heure", value=local_now.strftime('%d/%m/%Y à %H:%M'), inline=True)
log_embed.add_field(name="📤 De", value=source_channel.mention, inline=True)
log_embed.add_field(name="📥 Vers", value=f"{target_channel.name} ({type(target_channel).__name__})", inline=True)
log_embed.add_field(name="📝 Raison", value=reason, inline=False)
preview = content[:100] + "..." if content and len(content) > 100 else content
if preview:
log_embed.add_field(name="💬 Aperçu du message", value=preview, inline=False)
log_embed.set_footer(text=f"ID Auteur: {self.message.author.id} • Serveur: {interaction.guild.name}")
await send_to_moderation_log_channel(self.bot, log_embed)
class ForumTagSelect(Select):
def __init__(self, message: discord.Message, bot, target_channel: ForumChannel):
options = []
for tag in target_channel.available_tags[:25]:
options.append(discord.SelectOption(
label=tag.name,
value=str(tag.id),
emoji=tag.emoji if tag.emoji else None
))
super().__init__(
placeholder="Sélectionnez un ou plusieurs tags...",
options=options,
min_values=1,
max_values=min(5, len(options))
)
self.message = message
self.bot = bot
self.target_channel = target_channel
async def callback(self, interaction: discord.Interaction):
selected_tags = []
for tag_id_str in self.values:
tag_id = int(tag_id_str)
tag = discord.utils.get(self.target_channel.available_tags, id=tag_id)
if tag:
selected_tags.append(tag)
modal = TransferReasonModal(self.message, self.bot, self.target_channel, selected_tags)
await interaction.response.send_modal(modal)
class ForumTagView(View):
def __init__(self, message: discord.Message, bot, target_channel: ForumChannel):
super().__init__(timeout=180)
self.add_item(ForumTagSelect(message, bot, target_channel))
class TransferChannelSelect(ChannelSelect):
def __init__(self, message: discord.Message, bot):
super().__init__(
placeholder="Sélectionnez le canal de destination...",
channel_types=[discord.ChannelType.text, discord.ChannelType.forum, discord.ChannelType.public_thread, discord.ChannelType.private_thread],
min_values=1,
max_values=1
)
self.message = message
self.bot = bot
async def callback(self, interaction: discord.Interaction):
selected_channel = self.values[0]
target_channel = self.bot.get_channel(selected_channel.id)
if not target_channel:
await interaction.response.send_message("❌ Impossible de récupérer le canal sélectionné.", ephemeral=True)
return
if isinstance(target_channel, ForumChannel) and target_channel.available_tags:
view = ForumTagView(self.message, self.bot, target_channel)
await interaction.response.send_message("🏷️ Sélectionnez un ou plusieurs tags pour ce post :", view=view, ephemeral=True)
else:
modal = TransferReasonModal(self.message, self.bot, target_channel)
await interaction.response.send_modal(modal)
class TransferView(View):
def __init__(self, message: discord.Message, bot):
super().__init__(timeout=180)
self.add_item(TransferChannelSelect(message, bot))
@app_commands.context_menu(name="Déplacer le message")
@app_commands.default_permissions(manage_messages=True)
async def transfer_message_context_menu(interaction: discord.Interaction, message: discord.Message):
if not has_staff_role(interaction.user.roles):
await interaction.response.send_message("❌ Vous n'avez pas les permissions nécessaires pour utiliser cette commande.", ephemeral=True)
return
view = TransferView(message, interaction.client)
await interaction.response.send_message("📨 Sélectionnez le canal de destination :", view=view, ephemeral=True)
-603
View File
@@ -1,603 +0,0 @@
/* MVP.css v1.17.2 - https://github.com/andybrewer/mvp */
:root {
--active-brightness: 0.85;
--border-radius: 5px;
--box-shadow: 2px 2px 10px;
--color-accent: #118bee15;
--color-bg: #fff;
--color-bg-secondary: #e9e9e9;
--color-link: #118bee;
--color-secondary: #920de9;
--color-secondary-accent: #920de90b;
--color-shadow: #f4f4f4;
--color-table: #118bee;
--color-text: #000;
--color-text-secondary: #999;
--color-scrollbar: #cacae8;
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
--hover-brightness: 1.2;
--justify-important: center;
--justify-normal: left;
--line-height: 1.5;
--width-card: 285px;
--width-card-medium: 460px;
--width-card-wide: 800px;
--width-content: 1080px;
}
@media (prefers-color-scheme: dark) {
:root[color-mode="user"] {
--color-accent: #0097fc4f;
--color-bg: #333;
--color-bg-secondary: #555;
--color-link: #0097fc;
--color-secondary: #e20de9;
--color-secondary-accent: #e20de94f;
--color-shadow: #bbbbbb20;
--color-table: #0097fc;
--color-text: #f7f7f7;
--color-text-secondary: #aaa;
}
}
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
/* Layout */
article aside {
background: var(--color-secondary-accent);
border-left: 4px solid var(--color-secondary);
padding: 0.01rem 0.8rem;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family);
line-height: var(--line-height);
margin: 0;
overflow-x: hidden;
padding: 0;
}
footer,
header,
main {
margin: 0 auto;
max-width: var(--width-content);
/* padding: 3rem 1rem; */
padding: 1rem 1rem;
}
hr {
background-color: var(--color-bg-secondary);
border: none;
height: 1px;
margin: 4rem 0;
width: 100%;
}
section {
display: flex;
flex-wrap: wrap;
justify-content: var(--justify-important);
}
section img,
article img {
max-width: 100%;
}
section pre {
overflow: auto;
}
section aside {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
margin: 1rem;
padding: 1.25rem;
width: var(--width-card);
}
section aside:hover {
box-shadow: var(--box-shadow) var(--color-bg-secondary);
}
[hidden] {
display: none;
}
/* Headers */
article header,
div header,
main header {
padding-top: 0;
}
header {
text-align: var(--justify-important);
}
header a b,
header a em,
header a i,
header a strong {
margin-left: 0.5rem;
margin-right: 0.5rem;
}
/* header nav img {
margin: 1rem 0;
} */
section header {
padding-top: 0;
width: 100%;
}
/* Nav */
nav {
align-items: center;
display: flex;
font-weight: bold;
justify-content: space-between;
/* margin-bottom: 7rem; */
}
nav ul {
list-style: none;
padding: 0;
}
nav ul li {
display: inline-block;
margin: 0 0.5rem;
position: relative;
text-align: left;
}
/* Nav Dropdown */
nav ul li:hover ul {
display: block;
}
nav ul li ul {
background: var(--color-bg);
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
display: none;
height: auto;
left: -2px;
padding: 0.5rem 1rem;
position: absolute;
top: 1.7rem;
white-space: nowrap;
width: auto;
z-index: 1;
}
nav ul li ul::before {
/* fill gap above to make mousing over them easier */
content: "";
position: absolute;
left: 0;
right: 0;
top: -0.5rem;
height: 0.5rem;
}
nav ul li ul li,
nav ul li ul li a {
display: block;
}
/* Nav for Mobile */
@media (max-width: 768px) {
nav {
flex-wrap: wrap;
}
nav ul li {
width: calc(100% - 1em);
}
nav ul li ul {
border: none;
box-shadow: none;
display: block;
position: static;
}
}
/* Typography */
code,
samp {
background-color: var(--color-accent);
border-radius: var(--border-radius);
color: var(--color-text);
display: inline-block;
margin: 0 0.1rem;
padding: 0 0.5rem;
}
details {
margin: 1.3rem 0;
}
details summary {
font-weight: bold;
cursor: pointer;
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: var(--line-height);
text-wrap: balance;
}
mark {
padding: 0.1rem;
}
ol li,
ul li {
padding: 0.2rem 0;
}
p {
margin: 0.75rem 0;
padding: 0;
width: 100%;
}
pre {
margin: 1rem 0;
max-width: var(--width-card-wide);
padding: 1rem 0;
}
pre code,
pre samp {
display: block;
max-width: var(--width-card-wide);
padding: 0.5rem 2rem;
white-space: pre-wrap;
}
small {
color: var(--color-text-secondary);
}
sup {
background-color: var(--color-secondary);
border-radius: var(--border-radius);
color: var(--color-bg);
font-size: xx-small;
font-weight: bold;
margin: 0.2rem;
padding: 0.2rem 0.3rem;
position: relative;
top: -2px;
}
/* Links */
a {
color: var(--color-link);
display: inline-block;
font-weight: bold;
text-decoration: underline;
}
a:hover {
filter: brightness(var(--hover-brightness));
}
a:active {
filter: brightness(var(--active-brightness));
}
a b,
a em,
a i,
a strong,
button,
input[type="submit"] {
border-radius: var(--border-radius);
display: inline-block;
font-size: medium;
font-weight: bold;
line-height: var(--line-height);
margin: 0.5rem 0;
padding: 1rem 2rem;
}
button,
input[type="submit"] {
font-family: var(--font-family);
}
button:hover,
input[type="submit"]:hover {
cursor: pointer;
filter: brightness(var(--hover-brightness));
}
button:active,
input[type="submit"]:active {
filter: brightness(var(--active-brightness));
}
a b,
a strong,
button,
input[type="submit"] {
background-color: var(--color-link);
border: 2px solid var(--color-link);
color: var(--color-bg);
}
a em,
a i {
border: 2px solid var(--color-link);
border-radius: var(--border-radius);
color: var(--color-link);
display: inline-block;
padding: 1rem 2rem;
}
article aside a {
color: var(--color-secondary);
}
/* Images */
figure {
margin: 0;
padding: 0;
}
figure img {
max-width: 100%;
}
figure figcaption {
color: var(--color-text-secondary);
}
/* Forms */
button:disabled,
input:disabled {
background: var(--color-bg-secondary);
border-color: var(--color-bg-secondary);
color: var(--color-text-secondary);
cursor: not-allowed;
}
button[disabled]:hover,
input[type="submit"][disabled]:hover {
filter: none;
}
form {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
display: block;
max-width: var(--width-card-wide);
min-width: var(--width-card);
padding: 1.5rem;
text-align: var(--justify-normal);
}
form header {
margin: 1.5rem 0;
padding: 1.5rem 0;
}
input,
label,
select,
textarea {
display: block;
font-size: inherit;
max-width: var(--width-card-wide);
}
input[type="checkbox"],
input[type="radio"] {
display: inline-block;
}
input[type="checkbox"]+label,
input[type="radio"]+label {
display: inline-block;
font-weight: normal;
position: relative;
top: 1px;
}
input[type="range"] {
padding: 0.4rem 0;
}
input,
select,
textarea {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
margin-bottom: 1rem;
padding: 0.4rem 0.8rem;
}
input[type="text"],
input[type="password"],
input[type="email"],
textarea {
width: calc(100% - 1.6rem);
}
input[readonly],
textarea[readonly] {
background-color: var(--color-bg-secondary);
}
label {
font-weight: bold;
margin-bottom: 0.2rem;
}
/* Popups */
dialog {
max-width: 90%;
max-height: 85dvh;
margin: auto;
padding-block: 0;
padding-inline: 20px;
border: 1px solid var(--color-bg-secondary);
border-radius: 0.5rem;
overscroll-behavior: contain;
scroll-behavior: smooth;
scrollbar-width: none;
/* Hide scrollbar for Firefox */
-ms-overflow-style: none;
/* Hide scrollbar for IE and Edge */
scrollbar-color: transparent transparent;
animation: bottom-to-top 0.25s ease-in-out forwards;
}
dialog::-webkit-scrollbar {
width: 0;
display: none;
}
dialog::-webkit-scrollbar-track {
background: transparent;
}
dialog::-webkit-scrollbar-thumb {
background-color: transparent;
}
@media (min-width: 650px) {
dialog {
max-width: 39rem;
}
}
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
@keyframes bottom-to-top {
0% {
opacity: 0;
transform: translateY(10%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
dialog hr {
margin-block: 1rem;
}
/* Tables */
table {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
border-spacing: 0;
display: inline-block;
max-width: 100%;
overflow-x: auto;
padding: 0;
white-space: nowrap;
}
table td,
table th,
table tr {
padding: 0.4rem 0.8rem;
text-align: var(--justify-important);
}
table thead {
background-color: var(--color-table);
border-collapse: collapse;
border-radius: var(--border-radius);
color: var(--color-bg);
margin: 0;
padding: 0;
}
table thead tr:first-child th:first-child {
border-top-left-radius: var(--border-radius);
}
table thead tr:first-child th:last-child {
border-top-right-radius: var(--border-radius);
}
table thead th:first-child,
table tr td:first-child {
text-align: var(--justify-normal);
}
table tr:nth-child(even) {
background-color: var(--color-accent);
}
/* Quotes */
blockquote {
display: block;
font-size: x-large;
line-height: var(--line-height);
margin: 1rem auto;
max-width: var(--width-card-medium);
padding: 1.5rem 1rem;
text-align: var(--justify-important);
}
blockquote footer {
color: var(--color-text-secondary);
display: block;
font-size: small;
line-height: var(--line-height);
padding: 1.5rem 0;
}
/* Scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-scrollbar) transparent;
}
*::-webkit-scrollbar {
width: 5px;
height: 5px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar);
border-radius: 10px;
}
-19
View File
@@ -1,19 +0,0 @@
header nav img {
border-radius: 50%;
}
table th,
table td {
text-align: left;
vertical-align: top;
overflow: hidden;
white-space: normal;
}
table.live-alert tr td:last-child {
white-space: nowrap;
}
a.icon {
text-decoration: none;
}
+8
View File
@@ -95,6 +95,10 @@
<code class="text-slate-700 dark:text-slate-300 font-mono">!banlist</code>
<span class="text-slate-500 dark:text-slate-400">Liste des utilisateurs bannis</span>
</div>
<div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
<code class="text-slate-700 dark:text-slate-300 font-mono">!transfert #canal message_id</code>
<span class="text-slate-500 dark:text-slate-400">Transfère un message vers un autre canal (textuel, thread ou forum)</span>
</div>
</div>
</div>
</details>
@@ -129,6 +133,10 @@
<span class="text-xs font-medium text-yellow-600 dark:text-yellow-400">Warn</span>
{% elif mod_event.type == 'unban' %}
<span class="text-xs font-medium text-green-600 dark:text-green-400">Unban</span>
{% elif mod_event.type == 'transfer' %}
<span class="text-xs font-medium text-blue-600 dark:text-blue-400">Transfert</span>
{% elif mod_event.type == 'timeout' %}
<span class="text-xs font-medium text-purple-600 dark:text-purple-400">Timeout</span>
{% else %}
<span class="text-xs font-medium text-slate-600 dark:text-slate-400">{{ mod_event.type }}</span>
{% endif %}
+83 -26
View File
@@ -1,35 +1,92 @@
{% extends "template.html" %}
{% block content %}
<h1>Procédure de configuration de Twitch</h1>
<p>
<strong>Avant toute chose, activez l'authentification à deux facteurs (2FA) :</strong>
<a href="https://help.twitch.tv/s/article/two-factor-authentication?language=en_US" target="_blank">Guide officiel
Twitch pour la 2FA</a>
</p>
<p>
Rendez-vous sur <a href="https://dev.twitch.tv/console" target="_blank">la console d'applications Twitch</a> et
ajoutez une application. Renseignez :
<ul>
<li>URL de redirection : {{token_redirect_url}}</li>
<li>Catégorie : Chat Bot</li>
</ul>
</p>
<div class="mb-6">
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Configuration Twitch</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Guide étape par étape pour configurer l'API Twitch.
</p>
</div>
<img src="/static/img/twitch-api-01.jpg">
<div class="space-y-4">
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-400 text-xs font-medium">1</span>
<h2 class="font-medium text-slate-800 dark:text-white">Activer l'authentification à deux facteurs (2FA)</h2>
</div>
</div>
<div class="p-5">
<p class="text-sm text-slate-600 dark:text-slate-400 mb-4">
Avant de créer une application Twitch, vous devez activer la 2FA sur votre compte.
</p>
<a href="https://help.twitch.tv/s/article/two-factor-authentication?language=en_US" target="_blank" class="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-white">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
Guide officiel Twitch
</a>
</div>
</div>
<p>
Créez le bot. Puis, de retour à la liste, éditez-le en cliquant sur Gérer. Puis cliquez sur <strong>Nouveau
Secret</strong>. Vous trouverez ici le <strong>Client ID</strong> et le <strong>Client Secret</strong>.
</p>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-400 text-xs font-medium">2</span>
<h2 class="font-medium text-slate-800 dark:text-white">Créer une application Twitch</h2>
</div>
</div>
<div class="p-5 space-y-4">
<p class="text-sm text-slate-600 dark:text-slate-400">
Rendez-vous sur la console Twitch et créez une nouvelle application :
</p>
<a href="https://dev.twitch.tv/console" target="_blank" class="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-800 dark:bg-slate-700 text-white text-sm rounded-lg hover:bg-slate-700 dark:hover:bg-slate-600 transition-colors">
Console Twitch
</a>
<img src="/static/img/twitch-api-02.jpg">
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4 space-y-2 text-sm">
<div><span class="text-slate-500 dark:text-slate-400">URL de redirection :</span> <code class="ml-1 px-1.5 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs">{{ token_redirect_url }}</code></div>
<div><span class="text-slate-500 dark:text-slate-400">Catégorie :</span> <span class="ml-1 text-slate-800 dark:text-white">Chat Bot</span></div>
</div>
<p>
Ensuite, retournez sur la page de <a href="{{url_for('openConfigurations')}}">Configuration</a>, après avoir
enregistré le <strong>Client ID</strong> et le <strong>Client Secret</strong>, cliquez sur le lien <strong>Obtenir
token et refresh token</strong>. Si tout se passe bien les champs <strong>Access Token</strong> et
<strong>Refresh Token</strong> sont remplis.
</p>
<div class="rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
<img src="/static/img/twitch-api-01.jpg" alt="Création d'application Twitch" class="w-full">
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-400 text-xs font-medium">3</span>
<h2 class="font-medium text-slate-800 dark:text-white">Récupérer les identifiants</h2>
</div>
</div>
<div class="p-5 space-y-4">
<p class="text-sm text-slate-600 dark:text-slate-400">
Cliquez sur <strong class="text-slate-800 dark:text-white">Gérer</strong> puis <strong class="text-slate-800 dark:text-white">Nouveau Secret</strong> pour obtenir le Client ID et Client Secret.
</p>
<div class="rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
<img src="/static/img/twitch-api-02.jpg" alt="Récupération des identifiants" class="w-full">
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-400 text-xs font-medium">4</span>
<h2 class="font-medium text-slate-800 dark:text-white">Configurer Mamie Henriette</h2>
</div>
</div>
<div class="p-5 space-y-4">
<ol class="list-decimal list-inside space-y-1 text-sm text-slate-600 dark:text-slate-400">
<li>Entrez le Client ID et Client Secret</li>
<li>Cliquez sur Enregistrer</li>
<li>Cliquez sur "Obtenir token et refresh token"</li>
</ol>
<a href="{{ url_for('openConfigurations') }}" class="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-800 dark:bg-slate-700 text-white text-sm rounded-lg hover:bg-slate-700 dark:hover:bg-slate-600 transition-colors">
Aller à la Configuration
</a>
</div>
</div>
</div>
{% endblock %}
+1 -1
View File
@@ -218,7 +218,7 @@
{{ 'Enregistrer' if notification else 'Ajouter la notification' }}
</button>
{% if notification %}
<a href="{{ url_for('youtube') }}"
<a href="{{ url_for('openYouTube') }}"
class="px-6 py-2.5 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 font-medium rounded-lg transition-colors">
Annuler
</a>
+1 -1
View File
@@ -27,7 +27,7 @@ def twitchConfigurationHelp():
def twitchRequestToken():
global auth
helper = ConfigurationHelper()
twitch = asyncio.run(Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret')))
twitch = Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
auth = UserAuthenticator(twitch, USER_SCOPE, url=_buildUrl())
return redirect(auth.return_auth_url())