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:
+1
-1
@@ -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
@@ -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` (
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user