Refactor Discord member statistics handling and remove unused components
- Removed the `guild_member_stats` table and related logic from the database and models, as well as the associated webapp routes and templates. - Cleaned up the Discord bot initialization by eliminating unused imports and functions related to member statistics. - Updated logging configuration in `run-web.py` to streamline the startup process and improve thread management.
This commit is contained in:
@@ -227,31 +227,6 @@ def _doAddColumnMigrations(cursor: Cursor):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Table webapp_page_permission: {e}")
|
logging.warning(f"Table webapp_page_permission: {e}")
|
||||||
|
|
||||||
if not _tableExists('guild_member_stats', cursor):
|
|
||||||
try:
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE guild_member_stats (
|
|
||||||
guild_id VARCHAR(64) NOT NULL,
|
|
||||||
user_id VARCHAR(64) NOT NULL,
|
|
||||||
message_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
voice_seconds INTEGER NOT NULL DEFAULT 0,
|
|
||||||
updated_at DATETIME,
|
|
||||||
PRIMARY KEY (guild_id, user_id)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
logging.info("Table guild_member_stats créée")
|
|
||||||
except Exception as e:
|
|
||||||
logging.warning(f"Table guild_member_stats: {e}")
|
|
||||||
|
|
||||||
if _tableExists("webapp_page_permission", cursor):
|
|
||||||
try:
|
|
||||||
cursor.execute(
|
|
||||||
"INSERT OR IGNORE INTO webapp_page_permission (page_key, min_level, write_level) VALUES ('discord_members', 1, 1)"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logging.warning(f"Permission page discord_members: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def _doSeedAuth(cursor: Cursor):
|
def _doSeedAuth(cursor: Cursor):
|
||||||
"""Seed rôles par défaut et permissions des pages si vides."""
|
"""Seed rôles par défaut et permissions des pages si vides."""
|
||||||
from database.models import ROLE_ORDER
|
from database.models import ROLE_ORDER
|
||||||
@@ -294,7 +269,6 @@ def _doSeedAuth(cursor: Cursor):
|
|||||||
("freeloot", 1, 2),
|
("freeloot", 1, 2),
|
||||||
("patreon", 1, 2),
|
("patreon", 1, 2),
|
||||||
("moderation", 1, 2),
|
("moderation", 1, 2),
|
||||||
("discord_members", 1, 1),
|
|
||||||
("users", 5, 5),
|
("users", 5, 5),
|
||||||
("settings", 5, 5),
|
("settings", 5, 5),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -260,12 +260,3 @@ class ModShoutboxMessage(db.Model):
|
|||||||
message = db.Column(db.String(500), nullable=False)
|
message = db.Column(db.String(500), nullable=False)
|
||||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
class GuildMemberStats(db.Model):
|
|
||||||
__tablename__ = 'guild_member_stats'
|
|
||||||
guild_id = db.Column(db.String(64), primary_key=True)
|
|
||||||
user_id = db.Column(db.String(64), primary_key=True)
|
|
||||||
message_count = db.Column(db.Integer, nullable=False, default=0)
|
|
||||||
voice_seconds = db.Column(db.Integer, nullable=False, default=0)
|
|
||||||
updated_at = db.Column(db.DateTime, nullable=True)
|
|
||||||
|
|
||||||
|
|||||||
@@ -99,15 +99,6 @@ CREATE TABLE IF NOT EXISTS `member_invites` (
|
|||||||
`join_date` DATETIME NOT NULL
|
`join_date` DATETIME NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `guild_member_stats` (
|
|
||||||
`guild_id` VARCHAR(64) NOT NULL,
|
|
||||||
`user_id` VARCHAR(64) NOT NULL,
|
|
||||||
`message_count` INTEGER NOT NULL DEFAULT 0,
|
|
||||||
`voice_seconds` INTEGER NOT NULL DEFAULT 0,
|
|
||||||
`updated_at` DATETIME,
|
|
||||||
PRIMARY KEY (`guild_id`, `user_id`)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `twitch_link_filter` (
|
CREATE TABLE IF NOT EXISTS `twitch_link_filter` (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
`enabled` BOOLEAN NOT NULL DEFAULT FALSE,
|
`enabled` BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
|||||||
@@ -29,13 +29,11 @@ from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInvit
|
|||||||
from discordbot.patreon import checkPatreonPosts
|
from discordbot.patreon import checkPatreonPosts
|
||||||
from discordbot.youtube import checkYouTubeVideos
|
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
|
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
|
||||||
from discordbot.member_stats import record_message, on_voice_state_update_track_voice
|
|
||||||
from discordbot.rate_limit_logging import build_discord_http_trace_config
|
|
||||||
from protondb import searhProtonDb
|
from protondb import searhProtonDb
|
||||||
|
|
||||||
class DiscordBot(discord.Client):
|
class DiscordBot(discord.Client):
|
||||||
def __init__(self, *, intents: discord.Intents):
|
def __init__(self, *, intents: discord.Intents):
|
||||||
super().__init__(intents=intents, http_trace=build_discord_http_trace_config())
|
super().__init__(intents=intents)
|
||||||
self.tree = app_commands.CommandTree(self)
|
self.tree = app_commands.CommandTree(self)
|
||||||
self.synced = False
|
self.synced = False
|
||||||
|
|
||||||
@@ -169,10 +167,7 @@ async def on_message(message: Message):
|
|||||||
|
|
||||||
# Gestion des messages dans les auto rooms (avant le check des commandes !)
|
# Gestion des messages dans les auto rooms (avant le check des commandes !)
|
||||||
await on_message_auto_rooms(bot, message)
|
await on_message_auto_rooms(bot, message)
|
||||||
|
|
||||||
if message.guild and not message.author.bot:
|
|
||||||
record_message(message.guild.id, message.author.id)
|
|
||||||
|
|
||||||
if not message.content.startswith('!'):
|
if not message.content.startswith('!'):
|
||||||
return
|
return
|
||||||
command_name = message.content.split()[0]
|
command_name = message.content.split()[0]
|
||||||
@@ -336,7 +331,6 @@ async def on_message(message: Message):
|
|||||||
@bot.event
|
@bot.event
|
||||||
async def on_voice_state_update(member: Member, before, after):
|
async def on_voice_state_update(member: Member, before, after):
|
||||||
await on_voice_state_update_auto_rooms(bot, member, before, after)
|
await on_voice_state_update_auto_rooms(bot, member, before, after)
|
||||||
on_voice_state_update_track_voice(member, before, after)
|
|
||||||
|
|
||||||
@bot.event
|
@bot.event
|
||||||
async def on_raw_reaction_add(payload: discord.RawReactionActionEvent):
|
async def on_raw_reaction_add(payload: discord.RawReactionActionEvent):
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
"""Compteurs messages / vocal par membre pour la webapp (table guild_member_stats)."""
|
|
||||||
import asyncio
|
|
||||||
import concurrent.futures
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import discord
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from webapp import webapp
|
|
||||||
from database import db
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# (guild_id, user_id) -> datetime début session vocale (UTC)
|
|
||||||
_voice_join_at: dict[tuple[int, int], datetime] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _now_utc() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def record_message(guild_id: int, user_id: int) -> None:
|
|
||||||
"""Incrémente message_count (hors bots)."""
|
|
||||||
try:
|
|
||||||
with webapp.app_context():
|
|
||||||
db.session.execute(
|
|
||||||
text("""
|
|
||||||
INSERT INTO guild_member_stats (guild_id, user_id, message_count, voice_seconds, updated_at)
|
|
||||||
VALUES (:gid, :uid, 1, 0, CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT(guild_id, user_id) DO UPDATE SET
|
|
||||||
message_count = guild_member_stats.message_count + 1,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
"""),
|
|
||||||
{"gid": str(guild_id), "uid": str(user_id)},
|
|
||||||
)
|
|
||||||
db.session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("record_message: %s", e)
|
|
||||||
try:
|
|
||||||
db.session.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def add_voice_seconds(guild_id: int, user_id: int, seconds: int) -> None:
|
|
||||||
if seconds <= 0:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
with webapp.app_context():
|
|
||||||
db.session.execute(
|
|
||||||
text("""
|
|
||||||
INSERT INTO guild_member_stats (guild_id, user_id, message_count, voice_seconds, updated_at)
|
|
||||||
VALUES (:gid, :uid, 0, :sec, CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT(guild_id, user_id) DO UPDATE SET
|
|
||||||
voice_seconds = guild_member_stats.voice_seconds + :sec,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
"""),
|
|
||||||
{"gid": str(guild_id), "uid": str(user_id), "sec": seconds},
|
|
||||||
)
|
|
||||||
db.session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("add_voice_seconds: %s", e)
|
|
||||||
try:
|
|
||||||
db.session.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _finalize_voice_session(guild_id: int, user_id: int, end: datetime) -> None:
|
|
||||||
key = (guild_id, user_id)
|
|
||||||
started = _voice_join_at.pop(key, None)
|
|
||||||
if started is None:
|
|
||||||
return
|
|
||||||
delta = (end - started).total_seconds()
|
|
||||||
add_voice_seconds(guild_id, user_id, int(delta))
|
|
||||||
|
|
||||||
|
|
||||||
def on_voice_state_update_track_voice(member: discord.Member, before: discord.VoiceState, after: discord.VoiceState) -> None:
|
|
||||||
if member.bot:
|
|
||||||
return
|
|
||||||
guild_id = member.guild.id
|
|
||||||
uid = member.id
|
|
||||||
bc = before.channel
|
|
||||||
ac = after.channel
|
|
||||||
if bc == ac:
|
|
||||||
return
|
|
||||||
now = _now_utc()
|
|
||||||
if bc is not None:
|
|
||||||
_finalize_voice_session(guild_id, uid, now)
|
|
||||||
if ac is not None:
|
|
||||||
_voice_join_at[(guild_id, uid)] = now
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_guild_members_snapshot(bot: discord.Client, guild_id: int | None) -> tuple[bool, str | None, dict]:
|
|
||||||
"""
|
|
||||||
Retourne (ok, erreur, payload) avec payload =
|
|
||||||
{ guild_id, guild_name, members: [ { id, display_name, name, avatar_url, joined_at, nick, roles } ] }
|
|
||||||
"""
|
|
||||||
guilds = list(bot.guilds)
|
|
||||||
if not guilds:
|
|
||||||
return False, "Le bot n'est sur aucun serveur.", {}
|
|
||||||
chosen: discord.Guild | None = None
|
|
||||||
if guild_id is not None:
|
|
||||||
chosen = discord.utils.get(guilds, id=guild_id)
|
|
||||||
if chosen is None:
|
|
||||||
return False, "Serveur Discord introuvable pour ce bot.", {}
|
|
||||||
else:
|
|
||||||
if len(guilds) == 1:
|
|
||||||
chosen = guilds[0]
|
|
||||||
else:
|
|
||||||
return (
|
|
||||||
False,
|
|
||||||
"Plusieurs serveurs : précisez ?guild_id=… dans l'URL.",
|
|
||||||
{"guilds": [{"id": g.id, "name": g.name} for g in guilds]},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(chosen.chunk(cache=True), timeout=120.0)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning(
|
|
||||||
"guild.chunk timeout (%s), suite avec le cache membres partiel",
|
|
||||||
chosen.name,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("guild.chunk: %s", e, exc_info=True)
|
|
||||||
members_out = []
|
|
||||||
for m in chosen.members:
|
|
||||||
if m.bot:
|
|
||||||
continue
|
|
||||||
role_list = [r for r in m.roles if r.name != "@everyone"]
|
|
||||||
role_list.sort(key=lambda r: r.position, reverse=True)
|
|
||||||
roles = ", ".join(r.name for r in role_list[:8])
|
|
||||||
if len(role_list) > 8:
|
|
||||||
roles += f" (+{len(role_list) - 8})"
|
|
||||||
joined = m.joined_at.isoformat() if m.joined_at else None
|
|
||||||
members_out.append({
|
|
||||||
"id": str(m.id),
|
|
||||||
"display_name": m.display_name,
|
|
||||||
"name": m.name,
|
|
||||||
"avatar_url": m.display_avatar.url if m.display_avatar else "",
|
|
||||||
"joined_at": joined,
|
|
||||||
"nick": m.nick,
|
|
||||||
"roles": roles or "—",
|
|
||||||
})
|
|
||||||
members_out.sort(key=lambda x: (x["display_name"] or x["name"]).lower())
|
|
||||||
return True, None, {
|
|
||||||
"guild_id": str(chosen.id),
|
|
||||||
"guild_name": chosen.name,
|
|
||||||
"members": members_out,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_discord_members_snapshot_sync(bot: discord.Client, guild_id: int | None = None, timeout: float = 180.0) -> tuple[bool, str | None, dict]:
|
|
||||||
"""Appel thread-safe depuis Flask (run_coroutine_threadsafe sur la boucle du bot)."""
|
|
||||||
loop = getattr(bot, "loop", None)
|
|
||||||
if loop is None:
|
|
||||||
logger.error("get_discord_members_snapshot_sync: bot.loop est None (bot non démarré ?)")
|
|
||||||
return False, "Bot Discord non démarré (aucune boucle événements).", {}
|
|
||||||
if not bot.is_ready():
|
|
||||||
return False, "Bot Discord pas encore prêt (connexion en cours). Réessayez dans quelques secondes.", {}
|
|
||||||
try:
|
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
|
||||||
fetch_guild_members_snapshot(bot, guild_id),
|
|
||||||
loop,
|
|
||||||
)
|
|
||||||
ok, err, payload = future.result(timeout=timeout)
|
|
||||||
return ok, err, payload
|
|
||||||
except (concurrent.futures.TimeoutError, TimeoutError) as e:
|
|
||||||
logger.error(
|
|
||||||
"get_discord_members_snapshot_sync: délai dépassé après %.0fs (%s)",
|
|
||||||
timeout,
|
|
||||||
type(e).__name__,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
False,
|
|
||||||
"Délai dépassé pendant le chargement des membres (serveur volumineux ou bot occupé). Réessayez dans un instant.",
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("get_discord_members_snapshot_sync: %s", type(e).__name__)
|
|
||||||
msg = str(e) if str(e) else repr(e)
|
|
||||||
return False, msg or "Erreur lors de l’appel au bot Discord.", {}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""
|
|
||||||
Journalisation des en-têtes HTTP sur les réponses 429 (rate limit Discord).
|
|
||||||
|
|
||||||
Réf. Discord : https://support-dev.discord.com/hc/en-us/articles/6223003921559-My-Bot-is-Being-Rate-Limited
|
|
||||||
|
|
||||||
discord.py transmet ce TraceConfig aiohttp via l’option Client(http_trace=...).
|
|
||||||
Le corps JSON (retry_after, global) est toujours loggé par le logger discord.http.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
logger = logging.getLogger('discord.ratelimit_headers')
|
|
||||||
|
|
||||||
# En-têtes utiles pour identifier le type de limite (global / user / shared, etc.)
|
|
||||||
_HEADER_KEYS = (
|
|
||||||
'X-RateLimit-Limit',
|
|
||||||
'X-RateLimit-Remaining',
|
|
||||||
'X-RateLimit-Reset',
|
|
||||||
'X-RateLimit-Reset-After',
|
|
||||||
'X-RateLimit-Scope',
|
|
||||||
'X-Ratelimit-Bucket',
|
|
||||||
'X-Ratelimit-Limit',
|
|
||||||
'X-Ratelimit-Remaining',
|
|
||||||
'X-Ratelimit-Reset',
|
|
||||||
'Retry-After',
|
|
||||||
'Via',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_headers(resp: aiohttp.ClientResponse) -> dict[str, str]:
|
|
||||||
h = resp.headers
|
|
||||||
out: dict[str, str] = {}
|
|
||||||
for key in _HEADER_KEYS:
|
|
||||||
val = h.get(key)
|
|
||||||
if val is not None:
|
|
||||||
out[key] = val
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def build_discord_http_trace_config() -> aiohttp.TraceConfig:
|
|
||||||
trace = aiohttp.TraceConfig()
|
|
||||||
|
|
||||||
async def on_request_end(
|
|
||||||
session: aiohttp.ClientSession,
|
|
||||||
trace_config_ctx: Any,
|
|
||||||
params: Any,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
resp = getattr(params, 'response', None)
|
|
||||||
if resp is None or getattr(resp, 'status', None) != 429:
|
|
||||||
return
|
|
||||||
method = getattr(params, 'method', '?')
|
|
||||||
url = getattr(params, 'url', '?')
|
|
||||||
hdr = _collect_headers(resp)
|
|
||||||
logger.warning(
|
|
||||||
'Discord API 429 %s %s | rate_limit_headers=%s',
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
hdr,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.debug('rate_limit trace callback failed', exc_info=True)
|
|
||||||
|
|
||||||
trace.on_request_end.append(on_request_end)
|
|
||||||
return trace
|
|
||||||
+7
-27
@@ -2,7 +2,6 @@ import locale
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
from webapp import webapp
|
from webapp import webapp
|
||||||
@@ -38,25 +37,9 @@ if __name__ == '__main__':
|
|||||||
handlers.append(file_handler)
|
handlers.append(file_handler)
|
||||||
logging.basicConfig(level=logging.INFO, handlers=handlers)
|
logging.basicConfig(level=logging.INFO, handlers=handlers)
|
||||||
|
|
||||||
# Éviter les doublons de lignes discord (ex. discord.http sans [thread] puis avec) :
|
|
||||||
# discord.py peut attacher des handlers avant basicConfig ; on ne garde que la propagation vers la racine.
|
|
||||||
for _name in (
|
|
||||||
'discord',
|
|
||||||
'discord.client',
|
|
||||||
'discord.http',
|
|
||||||
'discord.gateway',
|
|
||||||
'discord.state',
|
|
||||||
'discord.webhook',
|
|
||||||
):
|
|
||||||
_lg = logging.getLogger(_name)
|
|
||||||
_lg.handlers.clear()
|
|
||||||
_lg.propagate = True
|
|
||||||
|
|
||||||
# Calmer les logs verbeux de certaines libs si besoin
|
# Calmer les logs verbeux de certaines libs si besoin
|
||||||
logging.getLogger('werkzeug').setLevel(logging.WARNING)
|
logging.getLogger('werkzeug').setLevel(logging.WARNING)
|
||||||
logging.getLogger('discord').setLevel(logging.WARNING)
|
logging.getLogger('discord').setLevel(logging.WARNING)
|
||||||
# 429 : en-têtes X-RateLimit-* (voir discordbot/rate_limit_logging.py + doc Discord rate limits)
|
|
||||||
logging.getLogger('discord.ratelimit_headers').setLevel(logging.WARNING)
|
|
||||||
|
|
||||||
# Hook exceptions non-capturées (threads inclus)
|
# Hook exceptions non-capturées (threads inclus)
|
||||||
def _log_uncaught(exc_type, exc, tb):
|
def _log_uncaught(exc_type, exc, tb):
|
||||||
@@ -70,15 +53,12 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
locale.setlocale(locale.LC_TIME, 'fr_FR.UTF-8')
|
locale.setlocale(locale.LC_TIME, 'fr_FR.UTF-8')
|
||||||
|
|
||||||
t_discord = threading.Thread(target=start_discord_bot, name='discord-bot')
|
jobs = []
|
||||||
t_web = threading.Thread(target=start_server, name='web-server')
|
jobs.append(threading.Thread(target=start_discord_bot, name='discord-bot'))
|
||||||
t_twitch = threading.Thread(target=start_twitch_bot, name='twitch-bot')
|
jobs.append(threading.Thread(target=start_server, name='web-server'))
|
||||||
|
jobs.append(threading.Thread(target=start_twitch_bot, name='twitch-bot'))
|
||||||
|
|
||||||
# Démarrer Discord en premier : le 429 sur GET /users/@me est souvent un pic au login ;
|
for job in jobs:
|
||||||
# lancer Waitress + Twitch quelques secondes après évite la concurrence au tout début.
|
job.start()
|
||||||
t_discord.start()
|
for job in jobs:
|
||||||
time.sleep(3)
|
|
||||||
t_web.start()
|
|
||||||
t_twitch.start()
|
|
||||||
for job in (t_discord, t_web, t_twitch):
|
|
||||||
job.join()
|
job.join()
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ def load_user(user_id):
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
from webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, discord_members, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot, patreon
|
from webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot, patreon
|
||||||
|
|
||||||
from flask import request, redirect, url_for
|
from flask import request, redirect, url_for
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
from collections import defaultdict
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from flask import redirect, render_template, request, url_for
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from webapp import webapp
|
|
||||||
from webapp.auth import require_page
|
|
||||||
from database import db
|
|
||||||
from database.models import GuildMemberStats, ModerationEvent
|
|
||||||
from discordbot import bot
|
|
||||||
from discordbot.member_stats import get_discord_members_snapshot_sync
|
|
||||||
|
|
||||||
|
|
||||||
def _format_voice_seconds(sec: int) -> str:
|
|
||||||
if sec <= 0:
|
|
||||||
return "0 min"
|
|
||||||
h, sec = divmod(sec, 3600)
|
|
||||||
m, sec = divmod(sec, 60)
|
|
||||||
if h:
|
|
||||||
return f"{h}h {m}min"
|
|
||||||
if m:
|
|
||||||
return f"{m}min"
|
|
||||||
return f"{sec}s"
|
|
||||||
|
|
||||||
|
|
||||||
def _event_to_row(e: ModerationEvent) -> dict:
|
|
||||||
return {
|
|
||||||
"type": e.type or "—",
|
|
||||||
"created_at": e.created_at.strftime("%d/%m/%Y %H:%M") if e.created_at else "—",
|
|
||||||
"reason": (e.reason or "")[:500],
|
|
||||||
"staff_name": e.staff_name or "—",
|
|
||||||
"duration": e.duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _load_latest_invites(guild_id_str: str) -> dict[str, dict]:
|
|
||||||
rows = db.session.execute(
|
|
||||||
text("""
|
|
||||||
SELECT user_id, invite_code, inviter_name, join_date FROM (
|
|
||||||
SELECT user_id, invite_code, inviter_name, join_date,
|
|
||||||
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY join_date DESC, id DESC) AS rn
|
|
||||||
FROM member_invites WHERE guild_id = :gid
|
|
||||||
) WHERE rn = 1
|
|
||||||
"""),
|
|
||||||
{"gid": guild_id_str},
|
|
||||||
).mappings().all()
|
|
||||||
out = {}
|
|
||||||
for r in rows:
|
|
||||||
jd = r["join_date"]
|
|
||||||
out[str(r["user_id"])] = {
|
|
||||||
"invite_code": r["invite_code"] or "—",
|
|
||||||
"inviter_name": r["inviter_name"] or "—",
|
|
||||||
"join_date": jd.strftime("%d/%m/%Y %H:%M") if jd else "—",
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _load_sanctions_by_user(user_ids: list[str]) -> dict[str, list]:
|
|
||||||
by_user: dict[str, list] = defaultdict(list)
|
|
||||||
chunk = 400
|
|
||||||
for i in range(0, len(user_ids), chunk):
|
|
||||||
part = user_ids[i : i + chunk]
|
|
||||||
if not part:
|
|
||||||
continue
|
|
||||||
q = ModerationEvent.query.filter(ModerationEvent.discord_id.in_(part))
|
|
||||||
for e in q.all():
|
|
||||||
by_user[e.discord_id].append(e)
|
|
||||||
for uid in by_user:
|
|
||||||
by_user[uid].sort(key=lambda x: x.created_at or datetime.min, reverse=True)
|
|
||||||
return by_user
|
|
||||||
|
|
||||||
|
|
||||||
@webapp.route("/discord-members")
|
|
||||||
@webapp.route("/discord-members.html")
|
|
||||||
def discord_members_redirect():
|
|
||||||
"""Redirection : l’URL réelle de la page est /discord-membres (route Flask, pas un fichier .html)."""
|
|
||||||
return redirect(url_for("discord_members", **request.args), code=302)
|
|
||||||
|
|
||||||
|
|
||||||
@webapp.route("/discord-membres")
|
|
||||||
@require_page("discord_members")
|
|
||||||
def discord_members():
|
|
||||||
status = webapp.config.get("BOT_STATUS", {})
|
|
||||||
bot_connected = bool(status.get("discord_connected"))
|
|
||||||
|
|
||||||
guild_param = request.args.get("guild_id", type=int)
|
|
||||||
|
|
||||||
ok, err, payload = get_discord_members_snapshot_sync(bot, guild_id=guild_param)
|
|
||||||
|
|
||||||
guild_choices = payload.get("guilds") if payload else None
|
|
||||||
if not ok and guild_choices:
|
|
||||||
return render_template(
|
|
||||||
"discord_members.html",
|
|
||||||
bot_connected=bot_connected,
|
|
||||||
load_error=err,
|
|
||||||
guild_choices=guild_choices,
|
|
||||||
guild_name=None,
|
|
||||||
members=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
if not ok:
|
|
||||||
return render_template(
|
|
||||||
"discord_members.html",
|
|
||||||
bot_connected=bot_connected,
|
|
||||||
load_error=err or "Impossible de charger les membres.",
|
|
||||||
guild_choices=None,
|
|
||||||
guild_name=None,
|
|
||||||
members=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
guild_id_str = payload["guild_id"]
|
|
||||||
guild_name = payload["guild_name"]
|
|
||||||
raw_members = payload["members"]
|
|
||||||
user_ids = [m["id"] for m in raw_members]
|
|
||||||
|
|
||||||
stats_map = {
|
|
||||||
r.user_id: r
|
|
||||||
for r in GuildMemberStats.query.filter_by(guild_id=guild_id_str).all()
|
|
||||||
}
|
|
||||||
invites_map = _load_latest_invites(guild_id_str)
|
|
||||||
sanctions_by_user = _load_sanctions_by_user(user_ids)
|
|
||||||
|
|
||||||
members = []
|
|
||||||
for m in raw_members:
|
|
||||||
uid = m["id"]
|
|
||||||
joined_raw = m.get("joined_at")
|
|
||||||
joined_display = "—"
|
|
||||||
if joined_raw:
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(joined_raw.replace("Z", "+00:00"))
|
|
||||||
joined_display = dt.strftime("%d/%m/%Y %H:%M") + " UTC"
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
joined_display = joined_raw
|
|
||||||
st = stats_map.get(uid)
|
|
||||||
msg_c = st.message_count if st else 0
|
|
||||||
voice_s = st.voice_seconds if st else 0
|
|
||||||
inv = invites_map.get(uid, {"invite_code": "—", "inviter_name": "—", "join_date": "—"})
|
|
||||||
events = sanctions_by_user.get(uid, [])
|
|
||||||
sanction_rows = [_event_to_row(e) for e in events]
|
|
||||||
search_blob = f"{m.get('display_name') or ''} {m.get('name') or ''} {uid}".lower()
|
|
||||||
members.append({
|
|
||||||
**m,
|
|
||||||
"joined_display": joined_display,
|
|
||||||
"search_blob": search_blob,
|
|
||||||
"message_count": msg_c,
|
|
||||||
"voice_label": _format_voice_seconds(voice_s),
|
|
||||||
"voice_seconds": voice_s,
|
|
||||||
"invite_code": inv["invite_code"],
|
|
||||||
"inviter_name": inv["inviter_name"],
|
|
||||||
"invite_join_date": inv["join_date"],
|
|
||||||
"sanction_count": len(sanction_rows),
|
|
||||||
"sanctions": sanction_rows,
|
|
||||||
})
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"discord_members.html",
|
|
||||||
bot_connected=bot_connected,
|
|
||||||
load_error=None,
|
|
||||||
guild_choices=None,
|
|
||||||
guild_name=guild_name,
|
|
||||||
guild_id=guild_id_str,
|
|
||||||
members=members,
|
|
||||||
)
|
|
||||||
@@ -63,12 +63,6 @@ PAGE_METADATA = {
|
|||||||
"description": "Historique de modération Discord",
|
"description": "Historique de modération Discord",
|
||||||
"icon": "shield"
|
"icon": "shield"
|
||||||
},
|
},
|
||||||
"discord_members": {
|
|
||||||
"label": "Membres Discord",
|
|
||||||
"category": "moderation",
|
|
||||||
"description": "Liste des membres, activité et sanctions",
|
|
||||||
"icon": "users"
|
|
||||||
},
|
|
||||||
"twitch_moderation": {
|
"twitch_moderation": {
|
||||||
"label": "Modération Twitch",
|
"label": "Modération Twitch",
|
||||||
"category": "moderation",
|
"category": "moderation",
|
||||||
@@ -164,7 +158,6 @@ PAGE_KEYS = [
|
|||||||
("protondb", "ProtonDB"),
|
("protondb", "ProtonDB"),
|
||||||
("freeloot", "FreeLoot"),
|
("freeloot", "FreeLoot"),
|
||||||
("moderation", "Modération Discord"),
|
("moderation", "Modération Discord"),
|
||||||
("discord_members", "Membres Discord"),
|
|
||||||
("users", "Utilisateurs"),
|
("users", "Utilisateurs"),
|
||||||
("settings", "Paramètres"),
|
("settings", "Paramètres"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
{% extends "template.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="mb-6">
|
|
||||||
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Membres Discord</h1>
|
|
||||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
|
||||||
Rapport par membre : arrivée sur le serveur, invitation enregistrée, activité (messages et vocal depuis le suivi activé) et sanctions.
|
|
||||||
</p>
|
|
||||||
{% if guild_name %}
|
|
||||||
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 mt-1">{{ guild_name }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if not bot_connected %}
|
|
||||||
<div class="mb-6 rounded-lg border border-amber-200 dark:border-amber-900/50 bg-amber-50 dark:bg-amber-950/30 px-4 py-3 text-sm text-amber-900 dark:text-amber-200">
|
|
||||||
Le bot Discord semble déconnecté : la liste des membres peut être indisponible ou incomplète.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if load_error %}
|
|
||||||
<div class="mb-6 rounded-lg border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/30 px-4 py-3 text-sm text-red-800 dark:text-red-200">
|
|
||||||
{{ load_error }}
|
|
||||||
{% if guild_choices %}
|
|
||||||
<ul class="mt-3 space-y-2">
|
|
||||||
{% for g in guild_choices %}
|
|
||||||
<li>
|
|
||||||
<a href="{{ url_for('discord_members', guild_id=g.id) }}" class="font-medium text-primary-600 dark:text-primary-400 hover:underline">{{ g.name }}</a>
|
|
||||||
<span class="text-slate-500 dark:text-slate-400 font-mono text-xs ml-2">({{ g.id }})</span>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if not load_error and members|length == 0 and not guild_choices %}
|
|
||||||
<div class="rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-5 py-8 text-center text-slate-500 dark:text-slate-400 text-sm">
|
|
||||||
Aucun membre humain à afficher sur ce serveur.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if members %}
|
|
||||||
<div class="mb-4 flex flex-col sm:flex-row sm:items-center gap-3">
|
|
||||||
<label class="sr-only" for="member-search">Rechercher un membre</label>
|
|
||||||
<input type="search" id="member-search" placeholder="Rechercher par nom ou ID…" autocomplete="off"
|
|
||||||
class="w-full sm:max-w-md rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 px-4 py-2.5 text-sm text-slate-800 dark:text-white placeholder-slate-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500" />
|
|
||||||
<p class="text-xs text-slate-500 dark:text-slate-400">
|
|
||||||
Les compteurs <strong>messages</strong> et <strong>vocal</strong> sont cumulés à partir du déploiement du suivi (pas d’historique rétroactif). L’invitation provient du message de bienvenue si celui-ci était activé au moment du join.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
{% for m in members %}
|
|
||||||
<div data-member-card data-search="{{ m.search_blob }}" class="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 overflow-hidden shadow-sm hover:shadow-md transition-shadow">
|
|
||||||
<div class="p-4 flex gap-4">
|
|
||||||
<img src="{{ m.avatar_url }}" alt="" width="64" height="64" class="w-16 h-16 rounded-full flex-shrink-0 ring-2 ring-slate-200 dark:ring-slate-600 object-cover" loading="lazy" />
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h2 class="text-base font-semibold text-slate-900 dark:text-white truncate">{{ m.display_name }}</h2>
|
|
||||||
<p class="text-xs text-slate-500 dark:text-slate-400 font-mono truncate">@{{ m.name }} · {{ m.id }}</p>
|
|
||||||
</div>
|
|
||||||
{% if m.sanction_count > 0 %}
|
|
||||||
<span class="flex-shrink-0 text-xs font-semibold px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-200">{{ m.sanction_count }} sanction{% if m.sanction_count > 1 %}s{% endif %}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
|
||||||
<span class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-200">
|
|
||||||
<svg class="w-3.5 h-3.5 opacity-70" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path></svg>
|
|
||||||
{{ m.message_count }} msg
|
|
||||||
</span>
|
|
||||||
<span class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md bg-violet-100 dark:bg-violet-900/40 text-violet-800 dark:text-violet-200">
|
|
||||||
<svg class="w-3.5 h-3.5 opacity-70" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path></svg>
|
|
||||||
{{ m.voice_label }}
|
|
||||||
</span>
|
|
||||||
{% if m.joined_display != '—' %}
|
|
||||||
<span class="inline-flex items-center text-xs px-2 py-1 rounded-md bg-emerald-100 dark:bg-emerald-900/40 text-emerald-800 dark:text-emerald-200" title="Membre depuis">{{ m.joined_display }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<details class="group border-t border-slate-200 dark:border-slate-700">
|
|
||||||
<summary class="px-4 py-3 cursor-pointer text-sm font-medium text-primary-600 dark:text-primary-400 hover:bg-slate-50 dark:hover:bg-slate-700/40 flex items-center justify-between list-none">
|
|
||||||
<span>Rapport détaillé</span>
|
|
||||||
<svg class="w-4 h-4 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
|
|
||||||
</summary>
|
|
||||||
<div class="px-4 pb-4 pt-0 space-y-4 text-sm">
|
|
||||||
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-slate-600 dark:text-slate-300">
|
|
||||||
<div>
|
|
||||||
<dt class="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">Pseudo serveur</dt>
|
|
||||||
<dd>{{ m.nick or '—' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt class="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">Arrivée (Discord)</dt>
|
|
||||||
<dd>{{ m.joined_display }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt class="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">Invitation (DB)</dt>
|
|
||||||
<dd><code class="text-xs bg-slate-100 dark:bg-slate-700 px-1 rounded">{{ m.invite_code }}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt class="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">Invité par</dt>
|
|
||||||
<dd>{{ m.inviter_name }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-2">
|
|
||||||
<dt class="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">Date join enregistrée (welcome)</dt>
|
|
||||||
<dd>{{ m.invite_join_date }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-2">
|
|
||||||
<dt class="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">Rôles</dt>
|
|
||||||
<dd class="text-xs leading-relaxed">{{ m.roles }}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
{% if m.sanctions %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-2">Sanctions</h3>
|
|
||||||
<ul class="space-y-2 max-h-48 overflow-y-auto">
|
|
||||||
{% for s in m.sanctions %}
|
|
||||||
<li class="rounded-lg border border-slate-200 dark:border-slate-600 px-3 py-2 bg-slate-50 dark:bg-slate-900/50">
|
|
||||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
|
||||||
<span class="font-medium text-slate-800 dark:text-slate-200">{{ s.type }}</span>
|
|
||||||
<span class="text-xs text-slate-500">{{ s.created_at }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-slate-600 dark:text-slate-400 mt-1">{{ s.reason }}</p>
|
|
||||||
<p class="text-xs text-slate-500 mt-1">Par {{ s.staff_name }}{% if s.duration is not none %} · durée {{ s.duration }}s{% endif %}</p>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-xs text-slate-500 dark:text-slate-400">Aucune sanction enregistrée en base pour ce membre.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
var input = document.getElementById('member-search');
|
|
||||||
if (!input) return;
|
|
||||||
input.addEventListener('input', function() {
|
|
||||||
var q = (input.value || '').toLowerCase().trim();
|
|
||||||
document.querySelectorAll('[data-member-card]').forEach(function(el) {
|
|
||||||
var blob = (el.getAttribute('data-search') || '').toLowerCase();
|
|
||||||
el.style.display = !q || blob.indexOf(q) !== -1 ? '' : 'none';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -123,10 +123,6 @@
|
|||||||
<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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
<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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
||||||
Modération
|
Modération
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('discord_members') }}" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
|
||||||
<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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
|
||||||
Membres
|
|
||||||
</a>
|
|
||||||
<a href="/configurations#auto-rooms" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
<a href="/configurations#auto-rooms" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
<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="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg>
|
<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="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg>
|
||||||
Auto Rooms
|
Auto Rooms
|
||||||
@@ -249,10 +245,6 @@
|
|||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
||||||
Modération
|
Modération
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('discord_members') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
|
||||||
Membres Discord
|
|
||||||
</a>
|
|
||||||
<a href="/configurations#auto-rooms" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
<a href="/configurations#auto-rooms" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg>
|
||||||
Auto Rooms
|
Auto Rooms
|
||||||
|
|||||||
Reference in New Issue
Block a user