diff --git a/database/models.py b/database/models.py index 7b42a4c..fcd8227 100644 --- a/database/models.py +++ b/database/models.py @@ -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) diff --git a/database/schema.sql b/database/schema.sql index 87a1926..01d14eb 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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` ( diff --git a/discordbot/__init__.py b/discordbot/__init__.py index 589c99e..af33585 100644 --- a/discordbot/__init__.py +++ b/discordbot/__init__.py @@ -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) diff --git a/discordbot/rules_ack.py b/discordbot/rules_ack.py new file mode 100644 index 0000000..41cee5f --- /dev/null +++ b/discordbot/rules_ack.py @@ -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) diff --git a/webapp/configurations.py b/webapp/configurations.py index d6e0251..25d8d3f 100644 --- a/webapp/configurations.py +++ b/webapp/configurations.py @@ -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")) diff --git a/webapp/templates/configurations.html b/webapp/templates/configurations.html index cefb506..a32fd34 100644 --- a/webapp/templates/configurations.html +++ b/webapp/templates/configurations.html @@ -73,6 +73,90 @@ +
+

Règlement (embed + bouton)

+

+ 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. +

+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +

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.

+
+
+

Messages de départ

@@ -212,6 +296,14 @@ Enregistrer la configuration Discord + +
+

Envoie ou remplace le message du règlement sur Discord (utilise la config enregistrée ci-dessus).

+ +