Implement rules acknowledgment feature in Discord bot

- Added a new module for managing rules acknowledgment, including a persistent button for users to accept the rules.
- Updated the database schema to change the `value` column type in the `Configuration` model to `TEXT` for better handling of longer rule descriptions.
- Enhanced the web application to include configuration options for rules acknowledgment, allowing customization of the rules message and button label.
- Integrated the rules acknowledgment feature into the Discord bot, enabling automatic role assignment upon acceptance and handling of presentation messages for validated roles.
- Updated the configurations template to support new settings related to rules acknowledgment.
This commit is contained in:
2026-05-04 13:17:04 +02:00
parent e72647612d
commit 69d8167581
6 changed files with 353 additions and 5 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ class WebappUser(db.Model, UserMixin):
class Configuration(db.Model):
key = db.Column(db.String(32), primary_key=True)
value = db.Column(db.String(512))
value = db.Column(db.Text)
class Humeur(db.Model):
id = db.Column(db.Integer, primary_key=True)
+1 -1
View File
@@ -1,7 +1,7 @@
CREATE TABLE IF NOT EXISTS `configuration` (
`key` VARCHAR(32) PRIMARY KEY,
`value` VARCHAR(512) NOT NULL
`value` TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS `game_alias` (
+4
View File
@@ -33,6 +33,7 @@ from discordbot.moderation import (
moderation_slash_say,
)
from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
from discordbot.rules_ack import register_persistent_rules_view, on_presentation_message
from discordbot.patreon import checkPatreonPosts
from discordbot.youtube import checkYouTubeVideos
from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms, on_message_auto_rooms, cleanup_orphaned_auto_rooms
@@ -62,6 +63,8 @@ class DiscordBot(discord.Client):
):
self.tree.add_command(cmd)
logging.info("Commandes d'application (transfert, modération, ProtonDB) ajoutées au CommandTree")
register_persistent_rules_view(self)
logging.info("Vue persistante règlement (bouton) enregistrée")
async def on_ready(self):
logging.info(f'Connecté en tant que {self.user} (ID: {self.user.id})')
@@ -187,6 +190,7 @@ async def on_message(message: Message):
if message.author == bot.user:
return
await on_presentation_message(bot, message)
# Gestion des messages dans les auto rooms (avant le check des commandes !)
await on_message_auto_rooms(bot, message)
+199
View File
@@ -0,0 +1,199 @@
# Règlement Discord : embed + bouton persistant, rôles arrivée / validé, promo sur canal présentation.
import asyncio
import logging
import discord
from discord import TextChannel
from discord.ui import Button, View
from webapp import webapp
from database import db
from database.helpers import ConfigurationHelper
RULES_BUTTON_CUSTOM_ID = "mamie_rules_accept"
DEFAULT_BUTTON_LABEL = "J'ai lu le règlement"
class AcceptRulesButton(Button):
def __init__(self, label: str):
super().__init__(
style=discord.ButtonStyle.success,
label=(label or DEFAULT_BUTTON_LABEL)[:80],
custom_id=RULES_BUTTON_CUSTOM_ID,
)
async def callback(self, interaction: discord.Interaction):
await handle_rules_accept(interaction)
class RulesAcceptView(View):
def __init__(self, button_label: str):
super().__init__(timeout=None)
self.add_item(AcceptRulesButton(button_label))
def register_persistent_rules_view(client: discord.Client) -> None:
with webapp.app_context():
label = (ConfigurationHelper().getValue("rules_button_label") or "").strip() or DEFAULT_BUTTON_LABEL
client.add_view(RulesAcceptView(label))
async def handle_rules_accept(interaction: discord.Interaction) -> None:
if not interaction.guild or not isinstance(interaction.user, discord.Member):
await interaction.response.send_message("Action impossible dans ce contexte.", ephemeral=True)
return
member = interaction.user
with webapp.app_context():
config = ConfigurationHelper()
enabled = config.getValue("rules_ack_enable")
arrival_id = config.getIntValue("rules_arrival_role_id")
presentation_id = config.getIntValue("rules_presentation_channel_id")
validated_id = config.getIntValue("rules_validated_role_id")
if not enabled:
await interaction.response.send_message("Cette fonctionnalité est désactivée.", ephemeral=True)
return
if not arrival_id:
await interaction.response.send_message("Rôle d'arrivée non configuré.", ephemeral=True)
return
role = interaction.guild.get_role(arrival_id)
if not role:
await interaction.response.send_message("Rôle d'arrivée introuvable sur ce serveur.", ephemeral=True)
return
if role in member.roles:
await interaction.response.send_message("Tu as déjà accepté le règlement.", ephemeral=True)
return
try:
await member.add_roles(role, reason="Acceptation du règlement (bouton)")
except discord.Forbidden:
await interaction.response.send_message(
"Je n'ai pas la permission de t'attribuer ce rôle (rôle du bot trop bas ou « Gérer les rôles » manquant).",
ephemeral=True,
)
return
except discord.HTTPException as e:
await interaction.response.send_message(f"Erreur Discord : {e}", ephemeral=True)
return
presentation_ch = interaction.guild.get_channel(presentation_id)
presentation_ch = presentation_ch if isinstance(presentation_ch, TextChannel) else None
validated_role = interaction.guild.get_role(validated_id) if validated_id else None
base = f"C'est bon 😌 tu as maintenant le rôle **{role.name}**."
if presentation_ch and validated_role:
text = (
f"{base} va te présenter dans {presentation_ch.mention} "
f"pour recevoir **{validated_role.name}**."
)
elif presentation_ch:
text = f"{base} va te présenter dans {presentation_ch.mention}."
else:
text = base
await interaction.response.send_message(text, ephemeral=True)
async def publish_rules_embed(bot: discord.Client) -> tuple[bool, str]:
with webapp.app_context():
config = ConfigurationHelper()
if not config.getValue("rules_ack_enable"):
return False, "Activez d'abord « Règlement avec bouton » et enregistrez la configuration."
channel_id = config.getIntValue("rules_channel_id")
body = (config.getValue("rules_embed_body") or "").strip()
title = (config.getValue("rules_embed_title") or "").strip() or "Bienvenue"
button_label = (config.getValue("rules_button_label") or "").strip() or DEFAULT_BUTTON_LABEL
old_mid = config.getIntValue("rules_message_id")
old_ch_id = config.getIntValue("rules_message_channel_id")
if not channel_id:
return False, "Choisissez un canal du règlement."
if not body:
return False, "Le texte du règlement est vide."
channel = bot.get_channel(channel_id)
if not channel or not isinstance(channel, TextChannel):
return False, "Canal du règlement introuvable."
if len(body) > 4096:
body = body[:4093] + "..."
embed = discord.Embed(title=title, description=body, color=discord.Color.blurple())
view = RulesAcceptView(button_label)
try:
if old_mid and old_ch_id:
old_ch = bot.get_channel(old_ch_id)
if old_ch and isinstance(old_ch, TextChannel):
try:
old_msg = await old_ch.fetch_message(old_mid)
await old_msg.delete()
except (discord.NotFound, discord.Forbidden, discord.HTTPException):
pass
msg = await channel.send(embed=embed, view=view)
with webapp.app_context():
ConfigurationHelper().createOrUpdate("rules_message_id", str(msg.id))
ConfigurationHelper().createOrUpdate("rules_message_channel_id", str(channel.id))
db.session.commit()
return True, "Message du règlement publié sur Discord."
except discord.Forbidden:
return False, "Permission refusée pour envoyer ou supprimer un message dans ce canal."
except Exception as e:
logging.exception("publish_rules_embed")
return False, str(e)
def publish_rules_embed_sync(bot: discord.Client) -> tuple[bool, str]:
try:
future = asyncio.run_coroutine_threadsafe(publish_rules_embed(bot), bot.loop)
return future.result(timeout=30)
except Exception as e:
logging.exception("publish_rules_embed_sync")
return False, str(e)
async def on_presentation_message(bot: discord.Client, message: discord.Message) -> None:
if message.author.bot:
return
with webapp.app_context():
config = ConfigurationHelper()
if not config.getValue("rules_ack_enable"):
return
presentation_id = config.getIntValue("rules_presentation_channel_id")
if not presentation_id or message.channel.id != presentation_id:
return
arrival_id = config.getIntValue("rules_arrival_role_id")
validated_id = config.getIntValue("rules_validated_role_id")
if not validated_id or not arrival_id:
return
member = message.author
if not isinstance(member, discord.Member):
return
arrival_role = message.guild.get_role(arrival_id)
validated_role = message.guild.get_role(validated_id)
if not validated_role or not arrival_role:
return
if arrival_role not in member.roles:
return
if validated_role in member.roles:
return
try:
await member.add_roles(validated_role, reason="Présentation dans le canal configuré")
if arrival_role:
await member.remove_roles(arrival_role, reason="Membre validé après présentation")
except (discord.Forbidden, discord.HTTPException) as e:
logging.warning("on_presentation_message: %s", e)
+56 -3
View File
@@ -1,10 +1,35 @@
from flask import render_template, request, redirect, url_for
import asyncio
import logging
from flask import render_template, request, redirect, url_for, flash
from webapp import webapp
from webapp.auth import require_page
from database import db
from database.helpers import ConfigurationHelper
from discordbot import bot
RULES_FORM_KEYS = frozenset({
'rules_channel_id',
'rules_arrival_role_id',
'rules_validated_role_id',
'rules_presentation_channel_id',
'rules_embed_title',
'rules_embed_body',
'rules_button_label',
})
SKIP_FORM_KEYS = frozenset({
'moderation_staff_role_ids',
'rules_ack_section_in_form',
'moderation_roles_in_form',
})
def _form_int_str(raw: str | None) -> str:
s = (raw or '').strip()
return s if s.isdigit() else '0'
@webapp.route("/configurations")
@require_page("configurations")
def openConfigurations():
@@ -23,7 +48,8 @@ def updateConfiguration():
'welcome_enable': 'welcome_channel_id',
'leave_enable': 'leave_channel_id',
'auto_rooms_enable': 'auto_rooms_channel_id',
'twitch_commands_enable': 'twitch_channel'
'twitch_commands_enable': 'twitch_channel',
'rules_ack_enable': 'rules_channel_id',
}
# Ne mettre à jour les rôles staff que si la liste a été rendue dans le formulaire.
@@ -35,8 +61,20 @@ def updateConfiguration():
else:
ConfigurationHelper().createOrUpdate('moderation_staff_role_ids', '')
if request.form.get('rules_ack_section_in_form'):
ch = ConfigurationHelper()
ch.createOrUpdate('rules_channel_id', _form_int_str(request.form.get('rules_channel_id')))
ch.createOrUpdate('rules_arrival_role_id', _form_int_str(request.form.get('rules_arrival_role_id')))
ch.createOrUpdate('rules_validated_role_id', _form_int_str(request.form.get('rules_validated_role_id')))
ch.createOrUpdate('rules_presentation_channel_id', _form_int_str(request.form.get('rules_presentation_channel_id')))
ch.createOrUpdate('rules_embed_title', (request.form.get('rules_embed_title') or '').strip())
ch.createOrUpdate('rules_embed_body', request.form.get('rules_embed_body') or '')
ch.createOrUpdate('rules_button_label', (request.form.get('rules_button_label') or '').strip())
for key in request.form:
if key == 'moderation_staff_role_ids':
if key in SKIP_FORM_KEYS:
continue
if request.form.get('rules_ack_section_in_form') and key in RULES_FORM_KEYS:
continue
value = request.form.get(key)
if value and value.strip():
@@ -49,3 +87,18 @@ def updateConfiguration():
db.session.commit()
return redirect(request.referrer)
@webapp.route("/configurations/publish-rules", methods=['POST'])
@require_page("configurations")
def publishRulesMessage():
from discordbot.rules_ack import publish_rules_embed_sync
if not bot.loop or bot.loop.is_closed():
flash("Le bot Discord n'est pas connecté.", "error")
return redirect(url_for("openConfigurations"))
ok, msg = publish_rules_embed_sync(bot)
flash(msg, "success" if ok else "error")
if not ok:
logging.warning("publishRulesMessage: %s", msg)
return redirect(url_for("openConfigurations"))
+92
View File
@@ -73,6 +73,90 @@
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Règlement (embed + bouton)</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Publie un message fixe dans un canal avec un embed et un bouton « J'ai lu le règlement ». Au clic, le membre reçoit le rôle d'arrivée. Si un canal « présentation » est configuré, le premier message du membre dans ce canal lui attribue le rôle membre validé et retire le rôle d'arrivée.
</p>
<input type="hidden" name="rules_ack_section_in_form" value="1">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="rules_ack_enable" {% if configuration.getValue('rules_ack_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer le règlement avec bouton</span>
</label>
<div>
<label for="rules_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal du règlement (message + bouton)</label>
<select name="rules_channel_id" id="rules_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
<option value="">— Choisir un canal —</option>
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('rules_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="rules_embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre de l'embed</label>
<input name="rules_embed_title" id="rules_embed_title" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('rules_embed_title') or '' }}"
placeholder="Bienvenue"/>
</div>
<div>
<label for="rules_embed_body" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Texte du règlement (description de l'embed, markdown Discord)</label>
<textarea name="rules_embed_body" id="rules_embed_body" rows="8"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all resize-y"
placeholder="Lis le règlement puis clique sur le bouton ci-dessous…">{{ configuration.getValue('rules_embed_body') or '' }}</textarea>
</div>
<div>
<label for="rules_button_label" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Libellé du bouton</label>
<input name="rules_button_label" id="rules_button_label" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('rules_button_label') or '' }}"
placeholder="J'ai lu le règlement"/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="rules_arrival_role_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôle d'arrivée (au clic sur le bouton)</label>
<select name="rules_arrival_role_id" id="rules_arrival_role_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
<option value="">— Aucun —</option>
{% for guild_data in roles %}
<optgroup label="{{ guild_data.guild_name }}">
{% for role in guild_data.roles %}
<option value="{{ role.id }}" {% if configuration.getIntValue('rules_arrival_role_id') == role.id %}selected{% endif %}>{{ role.name }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</div>
<div>
<label for="rules_validated_role_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôle membre validé (accès au reste du serveur)</label>
<select name="rules_validated_role_id" id="rules_validated_role_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
<option value="">— Aucun —</option>
{% for guild_data in roles %}
<optgroup label="{{ guild_data.guild_name }}">
{% for role in guild_data.roles %}
<option value="{{ role.id }}" {% if configuration.getIntValue('rules_validated_role_id') == role.id %}selected{% endif %}>{{ role.name }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</div>
</div>
<div>
<label for="rules_presentation_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal présentation (optionnel)</label>
<select name="rules_presentation_channel_id" id="rules_presentation_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
<option value="">— Désactivé —</option>
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('rules_presentation_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Si renseigné : premier message du membre (qui a le rôle d'arrivée) dans ce canal → ajout du rôle validé et retrait du rôle d'arrivée.</p>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Messages de départ</h3>
@@ -212,6 +296,14 @@
Enregistrer la configuration Discord
</button>
</form>
<form action="{{ url_for('publishRulesMessage') }}" method="POST" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">Envoie ou remplace le message du règlement sur Discord (utilise la config <strong>enregistrée</strong> ci-dessus).</p>
<button type="submit"
class="px-6 py-2.5 bg-teal-600 hover:bg-teal-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Publier le message règlement sur Discord
</button>
</form>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">